@rubytech/create-maxy-code 0.1.222 → 0.1.224
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/installer-settings-permissions.test.js +112 -0
- package/dist/index.js +14 -46
- package/dist/permissions-seed.js +76 -0
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +6 -2
- package/payload/platform/plugins/docs/references/platform.md +4 -0
- package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
- package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
- package/payload/platform/scripts/check-plugin-references.mjs +256 -0
- package/payload/platform/scripts/setup-account.sh +20 -8
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/premium-plugins/teaching/PLUGIN.md +7 -7
- package/payload/premium-plugins/venture-studio/skills/brand-pack/SKILL.md +1 -1
- package/payload/platform/plugins/admin/__tests__/admin-can-enumerate.test.sh +0 -129
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Task 583 — Installer must seed permissions.defaultMode=bypassPermissions
|
|
2
|
+
// and permissions.allow=["*"] into the brand-scoped settings.json so
|
|
3
|
+
// claude.ai/code sessions never fall through to the Anthropic remote
|
|
4
|
+
// auto-classifier. Existing top-level keys must be preserved; an
|
|
5
|
+
// already-correct file must be a true no-op (mtime unchanged).
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, readFileSync, writeFileSync, statSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { seedBypassPermissionsSettings, assertBypassPermissionsSeed, BYPASS_PERMISSIONS_BLOCK } from "../permissions-seed.js";
|
|
12
|
+
function freshDir() {
|
|
13
|
+
return mkdtempSync(join(tmpdir(), "t583-"));
|
|
14
|
+
}
|
|
15
|
+
test("empty target: creates settings.json with the permissions block", () => {
|
|
16
|
+
const dir = freshDir();
|
|
17
|
+
try {
|
|
18
|
+
const result = seedBypassPermissionsSettings(dir);
|
|
19
|
+
assert.equal(result.action, "seeded");
|
|
20
|
+
assert.equal(result.path, join(dir, "settings.json"));
|
|
21
|
+
const parsed = JSON.parse(readFileSync(result.path, "utf-8"));
|
|
22
|
+
assert.deepEqual(parsed, { permissions: { defaultMode: "bypassPermissions", allow: ["*"], deny: [] } });
|
|
23
|
+
assert.equal(assertBypassPermissionsSeed(dir).status, "ok");
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
test("existing keys preserved verbatim; permissions added", () => {
|
|
30
|
+
const dir = freshDir();
|
|
31
|
+
try {
|
|
32
|
+
const settingsPath = join(dir, "settings.json");
|
|
33
|
+
const existing = {
|
|
34
|
+
enabledPlugins: { "memory@claude-plugins-official": true },
|
|
35
|
+
extraKnownMarketplaces: { "claude-plugins-official": { source: { source: "github", repo: "anthropics/claude-plugins-official" } } },
|
|
36
|
+
inputNeededNotifEnabled: false,
|
|
37
|
+
agentPushNotifEnabled: false,
|
|
38
|
+
model: "opus",
|
|
39
|
+
};
|
|
40
|
+
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
41
|
+
const result = seedBypassPermissionsSettings(dir);
|
|
42
|
+
assert.equal(result.action, "seeded");
|
|
43
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
44
|
+
assert.deepEqual(parsed.enabledPlugins, existing.enabledPlugins);
|
|
45
|
+
assert.deepEqual(parsed.extraKnownMarketplaces, existing.extraKnownMarketplaces);
|
|
46
|
+
assert.equal(parsed.inputNeededNotifEnabled, false);
|
|
47
|
+
assert.equal(parsed.agentPushNotifEnabled, false);
|
|
48
|
+
assert.equal(parsed.model, "opus");
|
|
49
|
+
assert.deepEqual(parsed.permissions, { defaultMode: "bypassPermissions", allow: ["*"], deny: [] });
|
|
50
|
+
assert.equal(assertBypassPermissionsSeed(dir).status, "ok");
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
test("idempotent: already-correct block triggers no write", () => {
|
|
57
|
+
const dir = freshDir();
|
|
58
|
+
try {
|
|
59
|
+
const settingsPath = join(dir, "settings.json");
|
|
60
|
+
writeFileSync(settingsPath, JSON.stringify({ model: "opus", permissions: { defaultMode: "bypassPermissions", allow: ["*"], deny: [] } }, null, 2));
|
|
61
|
+
const before = statSync(settingsPath).mtimeMs;
|
|
62
|
+
// Small busy-wait so a same-millisecond write would be detectable on
|
|
63
|
+
// filesystems with millisecond mtime granularity.
|
|
64
|
+
const target = Date.now() + 15;
|
|
65
|
+
while (Date.now() < target) { /* spin */ }
|
|
66
|
+
const result = seedBypassPermissionsSettings(dir);
|
|
67
|
+
assert.equal(result.action, "noop");
|
|
68
|
+
const after = statSync(settingsPath).mtimeMs;
|
|
69
|
+
assert.equal(after, before, "mtime must not change on noop");
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
test("malformed JSON: overwrites with the seeded block", () => {
|
|
76
|
+
const dir = freshDir();
|
|
77
|
+
try {
|
|
78
|
+
const settingsPath = join(dir, "settings.json");
|
|
79
|
+
writeFileSync(settingsPath, "{ not json");
|
|
80
|
+
const result = seedBypassPermissionsSettings(dir);
|
|
81
|
+
assert.equal(result.action, "seeded");
|
|
82
|
+
assert.equal(assertBypassPermissionsSeed(dir).status, "ok");
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
test("assert: missing file reports missing", () => {
|
|
89
|
+
const dir = freshDir();
|
|
90
|
+
try {
|
|
91
|
+
assert.equal(assertBypassPermissionsSeed(dir).status, "missing");
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
rmSync(dir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
test("assert: partial block (no wildcard) reports missing", () => {
|
|
98
|
+
const dir = freshDir();
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(join(dir, "settings.json"), JSON.stringify({ permissions: { defaultMode: "default", allow: [], deny: [] } }));
|
|
101
|
+
assert.equal(assertBypassPermissionsSeed(dir).status, "missing");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
test("BYPASS_PERMISSIONS_BLOCK frozen literal matches expected shape", () => {
|
|
108
|
+
assert.equal(BYPASS_PERMISSIONS_BLOCK.defaultMode, "bypassPermissions");
|
|
109
|
+
assert.deepEqual([...BYPASS_PERMISSIONS_BLOCK.allow], ["*"]);
|
|
110
|
+
assert.deepEqual([...BYPASS_PERMISSIONS_BLOCK.deny], []);
|
|
111
|
+
assert.ok(Object.isFrozen(BYPASS_PERMISSIONS_BLOCK));
|
|
112
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, statSync, realpathSync } from "node:fs";
|
|
4
4
|
import { registerSpecialistAgentsAt, SpecialistSymlinkCollision } from "./specialist-registration.js";
|
|
5
|
+
import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./permissions-seed.js";
|
|
5
6
|
import { resolve, join, dirname } from "node:path";
|
|
6
7
|
import { randomBytes } from "node:crypto";
|
|
7
8
|
import { resolveInstallPortFromFs, buildMaxyUnitFile, buildClaudeSessionManagerUnitFile, buildClaudePtysSliceUnitFile } from "./port-resolution.js";
|
|
@@ -18,7 +19,7 @@ import { classifyPortHolder } from "./preflight-port-classifier.js";
|
|
|
18
19
|
import { parsePluginList, computeInstallActions, computeConfigureActions, parseExternalPlugins, } from "./lib/plugin-install.js";
|
|
19
20
|
import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
|
|
20
21
|
import { pickLanInterface, mergeSmbConf, formatSambaMarker, } from "./samba-provision.js";
|
|
21
|
-
import { networkInterfaces, userInfo
|
|
22
|
+
import { networkInterfaces, userInfo } from "node:os";
|
|
22
23
|
const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
|
|
23
24
|
// Brand manifest — read from payload to derive all brand-specific installation values.
|
|
24
25
|
// The bundler stamps brand.json into the payload at build time.
|
|
@@ -953,6 +954,12 @@ function installClaudeCode() {
|
|
|
953
954
|
// pre-create the per-brand CLAUDE_CONFIG_DIR so the first
|
|
954
955
|
// `claude plugin marketplace add` has a directory to write into.
|
|
955
956
|
mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
957
|
+
// Task 583 — seed the brand-scoped permissions block so claude.ai/code
|
|
958
|
+
// sessions short-circuit Stage 1 of the local matcher and never fall
|
|
959
|
+
// through to the Anthropic remote auto-classifier (which routes Agent
|
|
960
|
+
// dispatch to `ask` and surfaces a prompt no unattended operator clicks).
|
|
961
|
+
const permissionsSeed = seedBypassPermissionsSettings(CLAUDE_CONFIG_DIR);
|
|
962
|
+
logFile(`[install-permissions] action=${permissionsSeed.action} path=${permissionsSeed.path}`);
|
|
956
963
|
const marketplaceList = spawnSync("claude", ["plugin", "marketplace", "list"], { stdio: "pipe", encoding: "utf-8", env: claudePluginEnv() });
|
|
957
964
|
if (marketplaceList.stderr)
|
|
958
965
|
process.stderr.write(marketplaceList.stderr);
|
|
@@ -2053,6 +2060,12 @@ function registerLocalAndExternalPlugins() {
|
|
|
2053
2060
|
throw new Error(`Plugin registration incomplete: expected marketplaces ${JSON.stringify([...expectedMarketplaces])} but \`claude plugin marketplace list\` returned ${JSON.stringify(actualMarketplaces)}. Missing: ${JSON.stringify(missing)}. Check setup.log for [plugin-marketplace] ERROR lines.`);
|
|
2054
2061
|
}
|
|
2055
2062
|
logFile(`[plugin-marketplace] assertion-source=claude-plugin-marketplace-list count=${actualMarketplaces.length} marketplaces=${JSON.stringify(actualMarketplaces)} CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}`);
|
|
2063
|
+
// Task 583 — post-install assertion that the brand-scoped permissions
|
|
2064
|
+
// block survived install. Warn-only: a missing block does not abort the
|
|
2065
|
+
// install (consistent with the marketplace ERROR path that logs and
|
|
2066
|
+
// continues per-entry), but surfaces in setup.log for diagnosis.
|
|
2067
|
+
const permissionsAssert = assertBypassPermissionsSeed(CLAUDE_CONFIG_DIR);
|
|
2068
|
+
logFile(`[install-permissions] brand-settings=${permissionsAssert.status} path=${permissionsAssert.path}`);
|
|
2056
2069
|
}
|
|
2057
2070
|
function buildPlatform() {
|
|
2058
2071
|
log("9", TOTAL, "Installing dependencies and building...");
|
|
@@ -3276,51 +3289,6 @@ WantedBy=multi-user.target
|
|
|
3276
3289
|
// and fail with EADDRINUSE. Fresh installs: the stop is a no-op.
|
|
3277
3290
|
const unitName = BRAND.serviceName.replace(".service", "");
|
|
3278
3291
|
const claudeSessionManagerUnitShort = claudeSessionManagerUnitName.replace(".service", "");
|
|
3279
|
-
// Task 579 — admin-enumeration acceptance gate. Runs against the
|
|
3280
|
-
// freshly-deployed payload before the systemctl restart cluster so a
|
|
3281
|
-
// regression in the admin tool surface fails the install and keeps
|
|
3282
|
-
// the old service running. The harness exit code is the gate; a
|
|
3283
|
-
// non-zero exit logs the JSONL evidence path to install.log and
|
|
3284
|
-
// process.exit(1) before we touch any unit. Set MAXY_SKIP_ENUMERATE_GATE=1
|
|
3285
|
-
// to bypass for cases where the operator is iterating on a different
|
|
3286
|
-
// surface and accepts the regression risk; absence is the default.
|
|
3287
|
-
if (!process.env.MAXY_SKIP_ENUMERATE_GATE) {
|
|
3288
|
-
const harness = join(INSTALL_DIR, "platform/plugins/admin/__tests__/admin-can-enumerate.test.sh");
|
|
3289
|
-
if (existsSync(harness)) {
|
|
3290
|
-
const brandConfigDir = join(homedir(), BRAND.configDir, ".claude");
|
|
3291
|
-
console.log(" Running admin-enumerate acceptance gate...");
|
|
3292
|
-
const gateResult = spawnSync("bash", [harness], {
|
|
3293
|
-
stdio: "inherit",
|
|
3294
|
-
env: {
|
|
3295
|
-
...process.env,
|
|
3296
|
-
CLAUDE_CONFIG_DIR: brandConfigDir,
|
|
3297
|
-
INSTALL_DIR,
|
|
3298
|
-
},
|
|
3299
|
-
});
|
|
3300
|
-
if (gateResult.status !== 0) {
|
|
3301
|
-
const installLog = join(homedir(), BRAND.configDir, "logs", "install.log");
|
|
3302
|
-
const ts = new Date().toISOString();
|
|
3303
|
-
try {
|
|
3304
|
-
mkdirSync(dirname(installLog), { recursive: true });
|
|
3305
|
-
appendFileSync(installLog, `${ts} [admin-enumerate-gate] FAIL exit=${gateResult.status ?? "null"} — admin seat could not locate seeded site within budget. Payload NOT swapped into systemd.\n`);
|
|
3306
|
-
}
|
|
3307
|
-
catch {
|
|
3308
|
-
// best-effort
|
|
3309
|
-
}
|
|
3310
|
-
console.error(`\n ❌ admin-enumerate gate failed (exit ${gateResult.status ?? "null"}).\n` +
|
|
3311
|
-
` The new payload is on disk at ${INSTALL_DIR} but systemd was NOT restarted.\n` +
|
|
3312
|
-
` Old service is still running. See ${installLog} for one-line summary.\n`);
|
|
3313
|
-
process.exit(1);
|
|
3314
|
-
}
|
|
3315
|
-
console.log(" admin-enumerate gate passed.");
|
|
3316
|
-
}
|
|
3317
|
-
else {
|
|
3318
|
-
console.log(` [admin-enumerate-gate] SKIP reason=harness-not-on-disk path=${harness}`);
|
|
3319
|
-
}
|
|
3320
|
-
}
|
|
3321
|
-
else {
|
|
3322
|
-
console.log(" [admin-enumerate-gate] SKIP reason=MAXY_SKIP_ENUMERATE_GATE=1");
|
|
3323
|
-
}
|
|
3324
3292
|
spawnSync("systemctl", ["--user", "stop", unitName], { stdio: "inherit" });
|
|
3325
3293
|
spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
|
|
3326
3294
|
spawnSync("systemctl", ["--user", "enable", edgeUnitShort], { stdio: "inherit" });
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Task 583 — Seed Claude Code's two-stage permission classifier with a
|
|
2
|
+
// wildcard allow so claude.ai/code sessions never fall through to the
|
|
3
|
+
// Anthropic remote auto-classifier. The classifier routes `Agent`/`Task`
|
|
4
|
+
// to `ask`, which surfaces as a permission prompt that an unattended
|
|
5
|
+
// operator never clicks — observed as 4-minute dead-air hangs on
|
|
6
|
+
// realagent-code.
|
|
7
|
+
//
|
|
8
|
+
// This module is the single writer for the brand-scoped settings.json
|
|
9
|
+
// permissions block. Account-scoped settings.json is written by
|
|
10
|
+
// platform/scripts/setup-account.sh in lockstep with the same shape.
|
|
11
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
export const BYPASS_PERMISSIONS_BLOCK = Object.freeze({
|
|
14
|
+
defaultMode: "bypassPermissions",
|
|
15
|
+
allow: ["*"],
|
|
16
|
+
deny: [],
|
|
17
|
+
});
|
|
18
|
+
function isAlreadyCorrect(permissions) {
|
|
19
|
+
if (!permissions || typeof permissions !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
const p = permissions;
|
|
22
|
+
if (p.defaultMode !== "bypassPermissions")
|
|
23
|
+
return false;
|
|
24
|
+
if (!Array.isArray(p.allow) || !p.allow.includes("*"))
|
|
25
|
+
return false;
|
|
26
|
+
if (!Array.isArray(p.deny) || p.deny.length !== 0)
|
|
27
|
+
return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
export function seedBypassPermissionsSettings(configDir) {
|
|
31
|
+
const settingsPath = join(configDir, "settings.json");
|
|
32
|
+
let existing = {};
|
|
33
|
+
if (existsSync(settingsPath)) {
|
|
34
|
+
const text = readFileSync(settingsPath, "utf-8");
|
|
35
|
+
if (text.trim().length > 0) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(text);
|
|
38
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
39
|
+
existing = parsed;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Malformed JSON: treat as empty and overwrite with a well-formed
|
|
44
|
+
// file containing only the permissions block. Any keys the prior
|
|
45
|
+
// file carried are unrecoverable from invalid JSON anyway.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (isAlreadyCorrect(existing.permissions)) {
|
|
50
|
+
return { action: "noop", path: settingsPath };
|
|
51
|
+
}
|
|
52
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
53
|
+
const merged = { ...existing, permissions: { ...BYPASS_PERMISSIONS_BLOCK, allow: [...BYPASS_PERMISSIONS_BLOCK.allow], deny: [] } };
|
|
54
|
+
const tmpPath = `${settingsPath}.task583.tmp`;
|
|
55
|
+
writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + "\n");
|
|
56
|
+
renameSync(tmpPath, settingsPath);
|
|
57
|
+
return { action: "seeded", path: settingsPath };
|
|
58
|
+
}
|
|
59
|
+
export function assertBypassPermissionsSeed(configDir) {
|
|
60
|
+
const settingsPath = join(configDir, "settings.json");
|
|
61
|
+
if (!existsSync(settingsPath))
|
|
62
|
+
return { status: "missing", path: settingsPath };
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { status: "missing", path: settingsPath };
|
|
69
|
+
}
|
|
70
|
+
if (!parsed || typeof parsed !== "object")
|
|
71
|
+
return { status: "missing", path: settingsPath };
|
|
72
|
+
const permissions = parsed.permissions;
|
|
73
|
+
return isAlreadyCorrect(permissions)
|
|
74
|
+
? { status: "ok", path: settingsPath }
|
|
75
|
+
: { status: "missing", path: settingsPath };
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:4b8d8eafde682590492196ad2fd4385193ccf3fd5623f5d0acda78d27bf58526
|
|
5
5
|
brand: maxy-code
|
|
6
6
|
product-name: Maxy
|
|
7
7
|
---
|
|
@@ -300,6 +300,10 @@ All three are tunable via env vars (`CLAUDE_SESSION_MANAGER_SPECIALIST_CAP`, `CL
|
|
|
300
300
|
|
|
301
301
|
If you suspect background processes are piling up, run `grep '\[reaper\]' ~/.{brand}/logs/server.log | tail -50` — each tick logs how many rows it scanned and reaped.
|
|
302
302
|
|
|
303
|
+
## Tool Permissions
|
|
304
|
+
|
|
305
|
+
Every install seeds a wildcard `permissions.allow:["*"]` plus `defaultMode:"bypassPermissions"` into both the brand-scoped settings file (`~/.{brand}/.claude/settings.json`) and every account-scoped one (`<install>/data/accounts/<id>/.claude/settings.json`). This stops Claude Code from sending tool calls to its remote auto-classifier, which would otherwise surface a permission prompt in the chat that an unattended session never answers. What each subagent is allowed to use is still controlled by the `tools:` line in its agent file, not by a top-level allowlist. To verify after an install: `cat ~/.{brand}/.claude/settings.json | jq '.permissions'`.
|
|
306
|
+
|
|
303
307
|
---
|
|
304
308
|
# Plugins Guide
|
|
305
309
|
Source: https://docs.getmaxy.com/plugins-guide.md
|
|
@@ -3752,7 +3756,7 @@ If the initial Cloudflare login fails during setup, Maxy will fall back to askin
|
|
|
3752
3756
|
Both flows run on the native Claude Code PTY surface in admin chat (Task 287). The retired action-runner / terminal-modal troubleshooting sections that lived here have been removed because those surfaces no longer exist; failures now manifest as plain stderr from the agent-invoked Bash command, visible in chat.
|
|
3753
3757
|
|
|
3754
3758
|
- **Software update.** Re-run `npx -y @rubytech/create-<brand>@latest` from a shell; if the installer fails, its stdout is the diagnostic record. HeaderMenu turns sage when `installed === latest`.
|
|
3755
|
-
- **Cloudflare setup.** The agent invokes `cloudflared` directly via Bash, following the cloudflare plugin's `references/manual-setup.md`. Failures surface as cloudflared's literal stderr plus a non-zero exit. Recovery paths live in `references/reset-guide.md` and `references/manual-setup.md`.
|
|
3759
|
+
- **Cloudflare setup.** The agent invokes `cloudflared` directly via Bash, following the cloudflare plugin's `plugins/cloudflare/references/manual-setup.md`. Failures surface as cloudflared's literal stderr plus a non-zero exit. Recovery paths live in `plugins/cloudflare/references/reset-guide.md` and `plugins/cloudflare/references/manual-setup.md`.
|
|
3756
3760
|
|
|
3757
3761
|
## Orphan Account Directory Archived to `.trash/`
|
|
3758
3762
|
|
|
@@ -187,3 +187,7 @@ Messages you write yourself (e.g. typing directly in WhatsApp) are not marked
|
|
|
187
187
|
All three are tunable via env vars (`CLAUDE_SESSION_MANAGER_SPECIALIST_CAP`, `CLAUDE_SESSION_MANAGER_OPERATOR_RESERVE`, `CLAUDE_SESSION_MANAGER_TOTAL_PTY_CAP`, `CLAUDE_SESSION_MANAGER_RECORDER_IDLE_TTL_MS`); developer details in `.docs/platform.md` § "Claude Session Manager — PTY Slot Safeguards".
|
|
188
188
|
|
|
189
189
|
If you suspect background processes are piling up, run `grep '\[reaper\]' ~/.{brand}/logs/server.log | tail -50` — each tick logs how many rows it scanned and reaped.
|
|
190
|
+
|
|
191
|
+
## Tool Permissions
|
|
192
|
+
|
|
193
|
+
Every install seeds a wildcard `permissions.allow:["*"]` plus `defaultMode:"bypassPermissions"` into both the brand-scoped settings file (`~/.{brand}/.claude/settings.json`) and every account-scoped one (`<install>/data/accounts/<id>/.claude/settings.json`). This stops Claude Code from sending tool calls to its remote auto-classifier, which would otherwise surface a permission prompt in the chat that an unattended session never answers. What each subagent is allowed to use is still controlled by the `tools:` line in its agent file, not by a top-level allowlist. To verify after an install: `cat ~/.{brand}/.claude/settings.json | jq '.permissions'`.
|
|
@@ -211,7 +211,7 @@ If the initial Cloudflare login fails during setup, {{productName}} will fall ba
|
|
|
211
211
|
Both flows run on the native Claude Code PTY surface in admin chat (Task 287). The retired action-runner / terminal-modal troubleshooting sections that lived here have been removed because those surfaces no longer exist; failures now manifest as plain stderr from the agent-invoked Bash command, visible in chat.
|
|
212
212
|
|
|
213
213
|
- **Software update.** Re-run `npx -y @rubytech/create-<brand>@latest` from a shell; if the installer fails, its stdout is the diagnostic record. HeaderMenu turns sage when `installed === latest`.
|
|
214
|
-
- **Cloudflare setup.** The agent invokes `cloudflared` directly via Bash, following the cloudflare plugin's `references/manual-setup.md`. Failures surface as cloudflared's literal stderr plus a non-zero exit. Recovery paths live in `references/reset-guide.md` and `references/manual-setup.md`.
|
|
214
|
+
- **Cloudflare setup.** The agent invokes `cloudflared` directly via Bash, following the cloudflare plugin's `plugins/cloudflare/references/manual-setup.md`. Failures surface as cloudflared's literal stderr plus a non-zero exit. Recovery paths live in `plugins/cloudflare/references/reset-guide.md` and `plugins/cloudflare/references/manual-setup.md`.
|
|
215
215
|
|
|
216
216
|
## Orphan Account Directory Archived to `.trash/`
|
|
217
217
|
|
|
@@ -150,4 +150,4 @@ Import `.ics` files uploaded by the user. Use `schedule-import-ics` with the att
|
|
|
150
150
|
Cron→RRULE mapping for recurring events: daily, weekly-with-days, and monthly-by-date patterns produce standard RRULE properties. Cron patterns that cannot map to RRULE (e.g. `0 8 1,15 * *`) produce individual VEVENTs per occurrence instead.
|
|
151
151
|
|
|
152
152
|
## References
|
|
153
|
-
- references/scheduling.md (
|
|
153
|
+
- plugins/business-assistant/references/scheduling.md (booking protocol, geographic clustering, briefings)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Task 581 — bundle-time existence check for `references/<file>.md` paths
|
|
3
|
+
// cited in PLUGIN.md, SKILL.md, and specialist agent template bodies.
|
|
4
|
+
//
|
|
5
|
+
// Why: the `plugin-read` tool resolves `pluginName + file` against
|
|
6
|
+
// `<platform>/plugins/<pluginName>/<file>`. When a doc body cites
|
|
7
|
+
// `references/foo.md` (or `plugins/<other>/references/foo.md`) and that file
|
|
8
|
+
// does not exist, the agent burns a tool call only to receive
|
|
9
|
+
// "Plugin file not found". This module fails the bundle before such a payload
|
|
10
|
+
// is assembled.
|
|
11
|
+
//
|
|
12
|
+
// Two citation shapes are extracted:
|
|
13
|
+
// 1. Bare `references/<file>.md` — resolves under the citing file's owning
|
|
14
|
+
// plugin directory (PLUGIN.md and skills/<slug>/SKILL.md only; specialist
|
|
15
|
+
// templates have no plugin owner, so the bare shape there is itself a
|
|
16
|
+
// violation).
|
|
17
|
+
// 2. `plugins/<name>/references/<file>.md` (optionally prefixed by
|
|
18
|
+
// `platform/`) — resolves at `<platformRoot>/plugins/<name>/references/
|
|
19
|
+
// <file>.md`.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { resolve, relative, join, dirname } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
|
|
25
|
+
// Citation shapes captured (all must end in `references/<file>.md`):
|
|
26
|
+
// references/<file>.md
|
|
27
|
+
// plugins/<name>/references/<file>.md
|
|
28
|
+
// plugins/<name>/skills/<slug>/references/<file>.md
|
|
29
|
+
// skills/<slug>/references/<file>.md
|
|
30
|
+
// platform/<any of the above starting with plugins/>
|
|
31
|
+
//
|
|
32
|
+
// The lookbehind excludes [a-z0-9_/-] so the capture cannot start mid-path
|
|
33
|
+
// (`foo/references/bar.md` does not match). `/` is in the excluded set —
|
|
34
|
+
// that prevents a mid-path `references/...` from being captured without its
|
|
35
|
+
// leading `skills/<slug>/` or `plugins/<name>/` prefix. Every legitimate
|
|
36
|
+
// citation root we want to recognise is named explicitly in the alternation.
|
|
37
|
+
const REF_PATTERN =
|
|
38
|
+
/(?:^|[^a-z0-9_/-])((?:platform\/)?(?:plugins\/[a-z0-9][a-z0-9-]*\/)?(?:skills\/[a-z0-9][a-z0-9-]*\/)?references\/[a-z0-9][a-z0-9-]*\.md)/g;
|
|
39
|
+
|
|
40
|
+
// Walk `dir` recursively, collecting every file whose basename matches `predicate`.
|
|
41
|
+
function walk(dir, predicate, out = []) {
|
|
42
|
+
if (!existsSync(dir)) return out;
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = readdirSync(dir);
|
|
46
|
+
} catch {
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const full = join(dir, entry);
|
|
51
|
+
let st;
|
|
52
|
+
try {
|
|
53
|
+
st = statSync(full);
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (st.isDirectory()) {
|
|
58
|
+
walk(full, predicate, out);
|
|
59
|
+
} else if (predicate(entry, full)) {
|
|
60
|
+
out.push(full);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Plugin root for a citing file, or null if there is none (specialist
|
|
67
|
+
// templates). For SKILL.md, walks dirname up past `skills/<slug>/` to the
|
|
68
|
+
// ancestor that contains the `skills` segment as a direct child.
|
|
69
|
+
function owningPluginRoot(filePath) {
|
|
70
|
+
if (filePath.endsWith("/PLUGIN.md")) {
|
|
71
|
+
return dirname(filePath);
|
|
72
|
+
}
|
|
73
|
+
if (filePath.endsWith("/SKILL.md")) {
|
|
74
|
+
let cursor = dirname(filePath);
|
|
75
|
+
while (cursor && cursor !== "/" && cursor !== ".") {
|
|
76
|
+
const parts = cursor.split("/");
|
|
77
|
+
if (parts[parts.length - 1] === "skills") {
|
|
78
|
+
return dirname(cursor);
|
|
79
|
+
}
|
|
80
|
+
cursor = dirname(cursor);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Directories to resolve a bare `references/<file>.md` citation against.
|
|
87
|
+
// Returns an array of candidate directories; the citation passes if the file
|
|
88
|
+
// exists under ANY of them. Both candidate paths for SKILL.md are reachable
|
|
89
|
+
// via `plugin-read` — the agent may construct `file=references/foo.md`
|
|
90
|
+
// (plugin-root form) or `file=skills/<slug>/references/foo.md` (skill-local
|
|
91
|
+
// form), depending on how the plugin organises its references.
|
|
92
|
+
//
|
|
93
|
+
// PLUGIN.md — plugin-root only. → [<plugin-root>]
|
|
94
|
+
// SKILL.md — skill-local OR plugin-root.
|
|
95
|
+
// → [<skill-dir>, <plugin-root>]
|
|
96
|
+
// specialist template — no owning context. → []
|
|
97
|
+
function bareReferenceContextDirs(filePath) {
|
|
98
|
+
if (filePath.endsWith("/PLUGIN.md")) {
|
|
99
|
+
return [dirname(filePath)];
|
|
100
|
+
}
|
|
101
|
+
if (filePath.endsWith("/SKILL.md")) {
|
|
102
|
+
const skillDir = dirname(filePath);
|
|
103
|
+
const pluginRoot = owningPluginRoot(filePath);
|
|
104
|
+
return pluginRoot ? [skillDir, pluginRoot] : [skillDir];
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Return every candidate absolute path a citation could resolve to. The
|
|
110
|
+
// citation passes the existence check if ANY candidate exists. Returns an
|
|
111
|
+
// empty array if the citation has no resolvable shape (bare reference cited
|
|
112
|
+
// from a non-plugin file).
|
|
113
|
+
function candidateTargets({ raw, sourcePath, platformRoot }) {
|
|
114
|
+
// Strip an optional leading `platform/` so `platform/plugins/<name>/...`
|
|
115
|
+
// and `plugins/<name>/...` resolve identically.
|
|
116
|
+
const normalised = raw.replace(/^platform\//, "");
|
|
117
|
+
|
|
118
|
+
// Cross-plugin (and cross-plugin-sub-skill) references resolve at the
|
|
119
|
+
// platform root regardless of the citing file's owning plugin.
|
|
120
|
+
if (normalised.startsWith("plugins/")) {
|
|
121
|
+
return [resolve(platformRoot, normalised)];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `skills/<slug>/references/<file>.md` resolves under the citing file's
|
|
125
|
+
// owning plugin directory. Used by PLUGIN.md bodies that index references
|
|
126
|
+
// owned by sub-skills (e.g. premium-plugins/teaching/PLUGIN.md).
|
|
127
|
+
if (normalised.startsWith("skills/")) {
|
|
128
|
+
const pluginRoot = owningPluginRoot(sourcePath);
|
|
129
|
+
return pluginRoot ? [resolve(pluginRoot, normalised)] : [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Bare `references/<file>.md`.
|
|
133
|
+
const ctxDirs = bareReferenceContextDirs(sourcePath);
|
|
134
|
+
return ctxDirs.map(d => resolve(d, normalised));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function scanFile(filePath, { platformRoot, repoRoot }) {
|
|
138
|
+
let body;
|
|
139
|
+
try {
|
|
140
|
+
body = readFileSync(filePath, "utf-8");
|
|
141
|
+
} catch {
|
|
142
|
+
return { violations: [], citations: 0 };
|
|
143
|
+
}
|
|
144
|
+
const violations = [];
|
|
145
|
+
let citations = 0;
|
|
146
|
+
const lines = body.split("\n");
|
|
147
|
+
for (let i = 0; i < lines.length; i++) {
|
|
148
|
+
const line = lines[i];
|
|
149
|
+
REF_PATTERN.lastIndex = 0;
|
|
150
|
+
let m;
|
|
151
|
+
while ((m = REF_PATTERN.exec(line)) !== null) {
|
|
152
|
+
const raw = m[1];
|
|
153
|
+
// 1-based column, pointing at the start of the captured citation.
|
|
154
|
+
const column = (m.index + m[0].indexOf(raw)) + 1;
|
|
155
|
+
citations++;
|
|
156
|
+
const candidates = candidateTargets({ raw, sourcePath: filePath, platformRoot });
|
|
157
|
+
if (candidates.length === 0) {
|
|
158
|
+
violations.push({
|
|
159
|
+
source: relative(repoRoot, filePath),
|
|
160
|
+
line: i + 1,
|
|
161
|
+
column,
|
|
162
|
+
target: "<no plugin owner — bare references/ cited from non-plugin file>",
|
|
163
|
+
raw,
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!candidates.some(p => existsSync(p))) {
|
|
168
|
+
// Report the most informative candidate — for SKILL.md citations the
|
|
169
|
+
// first candidate is the skill-local path; if both exist as candidates,
|
|
170
|
+
// listing both makes the fix unambiguous.
|
|
171
|
+
const targetMsg = candidates.length === 1
|
|
172
|
+
? candidates[0]
|
|
173
|
+
: `none of [${candidates.join(", ")}]`;
|
|
174
|
+
violations.push({
|
|
175
|
+
source: relative(repoRoot, filePath),
|
|
176
|
+
line: i + 1,
|
|
177
|
+
column,
|
|
178
|
+
target: targetMsg,
|
|
179
|
+
raw,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { violations, citations };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function runPluginReferenceCheck({ platformRoot, premiumRoot }) {
|
|
188
|
+
const repoRoot = resolve(platformRoot, "..");
|
|
189
|
+
const sources = [];
|
|
190
|
+
|
|
191
|
+
// platform: every PLUGIN.md and every SKILL.md under plugins/
|
|
192
|
+
const platformPlugins = resolve(platformRoot, "plugins");
|
|
193
|
+
walk(
|
|
194
|
+
platformPlugins,
|
|
195
|
+
(name) => name === "PLUGIN.md" || name === "SKILL.md",
|
|
196
|
+
sources,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// platform: every specialist template
|
|
200
|
+
const specialistDir = resolve(platformRoot, "templates", "specialists", "agents");
|
|
201
|
+
walk(specialistDir, (name) => name.endsWith(".md"), sources);
|
|
202
|
+
|
|
203
|
+
// premium: every PLUGIN.md and SKILL.md under any bundle (standalone or sub)
|
|
204
|
+
if (premiumRoot && existsSync(premiumRoot)) {
|
|
205
|
+
walk(
|
|
206
|
+
premiumRoot,
|
|
207
|
+
(name) => name === "PLUGIN.md" || name === "SKILL.md",
|
|
208
|
+
sources,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const violations = [];
|
|
213
|
+
let citations = 0;
|
|
214
|
+
for (const src of sources) {
|
|
215
|
+
const r = scanFile(src, { platformRoot, repoRoot });
|
|
216
|
+
violations.push(...r.violations);
|
|
217
|
+
citations += r.citations;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
ok: violations.length === 0,
|
|
222
|
+
violations,
|
|
223
|
+
scanned: sources.length,
|
|
224
|
+
citations,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// CLI entry — `node check-plugin-references.mjs [platformRoot] [premiumRoot]`.
|
|
229
|
+
// Use `pathToFileURL` so the self-detect survives paths with spaces or non-
|
|
230
|
+
// ASCII characters; the string-concat form fails because `process.argv[1]` is
|
|
231
|
+
// raw while `import.meta.url` is percent-encoded. When this module is loaded
|
|
232
|
+
// via `import` (e.g. from `bundle.js`), `process.argv[1]` may still be set
|
|
233
|
+
// to the importing script, in which case the URLs differ and the CLI block
|
|
234
|
+
// is skipped.
|
|
235
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
236
|
+
const defaultPlatform = resolve(import.meta.dirname, "..");
|
|
237
|
+
const defaultPremium = resolve(import.meta.dirname, "..", "..", "premium-plugins");
|
|
238
|
+
const platformRoot = process.argv[2] ? resolve(process.argv[2]) : defaultPlatform;
|
|
239
|
+
const premiumRoot = process.argv[3] ? resolve(process.argv[3]) : defaultPremium;
|
|
240
|
+
const r = runPluginReferenceCheck({ platformRoot, premiumRoot });
|
|
241
|
+
if (!r.ok) {
|
|
242
|
+
for (const v of r.violations) {
|
|
243
|
+
console.error(`[bundle-validator] reference not found`);
|
|
244
|
+
console.error(` source: ${v.source}:${v.line}:${v.column}`);
|
|
245
|
+
console.error(` cites: ${v.raw}`);
|
|
246
|
+
console.error(` target: ${v.target}`);
|
|
247
|
+
}
|
|
248
|
+
console.error(
|
|
249
|
+
`FATAL: ${r.violations.length} broken reference citation(s) across ${r.scanned} scanned file(s) (${r.citations} citations).`,
|
|
250
|
+
);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
console.log(
|
|
254
|
+
`[bundle-validator] plugin references ok scanned=${r.scanned} citations=${r.citations}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -44,15 +44,22 @@ mkdir -p "$ACCOUNT_DIR/agents/admin" "$ACCOUNT_DIR/agents/public" "$ACCOUNT_DIR/
|
|
|
44
44
|
[ -d "$ACCOUNT_DIR/.git" ] || git init -q "$ACCOUNT_DIR"
|
|
45
45
|
|
|
46
46
|
# ------------------------------------------------------------------
|
|
47
|
-
# 2. Write project-level .claude/settings.json (
|
|
47
|
+
# 2. Write project-level .claude/settings.json (permissions + hooks)
|
|
48
48
|
# ------------------------------------------------------------------
|
|
49
|
-
# Task 453
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
49
|
+
# Task 453 removed `permissions.allow` because its short-prefix entries
|
|
50
|
+
# (mcp__memory__*, mcp__contacts__*, …) never matched the plugin-manifest
|
|
51
|
+
# long-prefix tool names Claude Code actually emits (mcp__plugin_memory_memory__…),
|
|
52
|
+
# so every Task-subagent plugin call was denied at use-time.
|
|
53
|
+
#
|
|
54
|
+
# Task 583 reinstates a `permissions` block — but as wildcard
|
|
55
|
+
# `allow: ["*"]` plus `defaultMode: "bypassPermissions"`. The wildcard
|
|
56
|
+
# bypasses the prefix-matching defect Task 453 was avoiding (a list of one
|
|
57
|
+
# entry that matches everything cannot mismatch), and `bypassPermissions`
|
|
58
|
+
# short-circuits Stage 1 of the local matcher so claude.ai/code sessions
|
|
59
|
+
# never fall through to the Anthropic remote auto-classifier (which routes
|
|
60
|
+
# Agent dispatch to `ask` and surfaces a permission prompt no unattended
|
|
61
|
+
# operator clicks). Per-agent frontmatter `tools:` lists remain the
|
|
62
|
+
# permission surface for what each subagent is allowed to invoke.
|
|
56
63
|
#
|
|
57
64
|
# Hook commands use $PLATFORM_ROOT (set by claude-agent.ts at spawn time)
|
|
58
65
|
# to locate scripts relative to the platform installation, not the account dir.
|
|
@@ -61,6 +68,11 @@ HOOKS_PATH="\$PLATFORM_ROOT/plugins/admin/hooks"
|
|
|
61
68
|
WRITER_CRAFT_HOOKS_PATH="\$PLATFORM_ROOT/../premium-plugins/writer-craft/hooks"
|
|
62
69
|
cat > "$ACCOUNT_SETTINGS" << SETTINGS_EOF
|
|
63
70
|
{
|
|
71
|
+
"permissions": {
|
|
72
|
+
"defaultMode": "bypassPermissions",
|
|
73
|
+
"allow": ["*"],
|
|
74
|
+
"deny": []
|
|
75
|
+
},
|
|
64
76
|
"hooks": {
|
|
65
77
|
"PreToolUse": [
|
|
66
78
|
{
|
|
@@ -23,7 +23,7 @@ These three rules win when anything else in this prompt conflicts with them.
|
|
|
23
23
|
Each domain has a small set of tools and, where it exists, a skill that drives the multi-step flow. Match the brief to the domain, load the skill if one is named, and run the tools the skill prescribes.
|
|
24
24
|
|
|
25
25
|
- **WhatsApp setup or config:** **After routing, the first tool call MUST be `Skill connect-whatsapp` (for QR pairing and admin-phone setup) or `Skill manage-whatsapp-config` (for DM/group policies). Any channel tool call before the routed skill's content is loaded into context is a contract violation.** Load `skill-load skillName=connect-whatsapp` for QR pairing and admin-phone setup; load `skill-load skillName=manage-whatsapp-config` for DM/group policies and admin-phone management. The skills carry the per-phase flow.
|
|
26
|
-
- **Cloudflare tunnel:** **After routing, the first tool call MUST be `Skill setup-tunnel`. Any Bash call before the skill content is loaded into context is a contract violation.** Load `skill-load skillName=setup-tunnel`. The skill drives `cloudflared` directly via Bash following `references/manual-setup.md`, `references/reset-guide.md`, and `references/dashboard-guide.md`. There is no shell-script wrapper; PTY stdout is the only surface.
|
|
26
|
+
- **Cloudflare tunnel:** **After routing, the first tool call MUST be `Skill setup-tunnel`. Any Bash call before the skill content is loaded into context is a contract violation.** Load `skill-load skillName=setup-tunnel`. The skill drives `cloudflared` directly via Bash following `plugins/cloudflare/references/manual-setup.md`, `plugins/cloudflare/references/reset-guide.md`, and `plugins/cloudflare/references/dashboard-guide.md`. There is no shell-script wrapper; PTY stdout is the only surface.
|
|
27
27
|
- **Every other domain** (scheduling, Telegram, email, Outlook, contacts, browser, platform admin) runs through the tool descriptions injected into your system prompt. The rules below apply across these domains regardless of which tool is invoked.
|
|
28
28
|
|
|
29
29
|
## Cross-domain rules
|
|
@@ -49,10 +49,10 @@ Domain knowledge files loaded on demand by each skill:
|
|
|
49
49
|
|
|
50
50
|
| Reference | Used By | Purpose |
|
|
51
51
|
|-----------|---------|---------|
|
|
52
|
-
| `references/assessment.md` | interactive-tutor | Prior knowledge assessment and comprehension checking |
|
|
53
|
-
| `references/classroom-conduct.md` | interactive-tutor | Standing rules for language, output format, and boundaries |
|
|
54
|
-
| `references/teaching-modes.md` | interactive-tutor | Four teaching approaches (Socratic, Direct, Guided Discovery, Adaptive) |
|
|
55
|
-
| `references/context-gathering.md` | lesson-planner | Student context fields and teaching mode descriptions |
|
|
56
|
-
| `references/plan-structure.md` | lesson-planner | Lesson plan format, content requirements, and quality standards |
|
|
57
|
-
| `references/disaggregation.md` | study-pack-builder | Building topic hierarchies from knowledge base content |
|
|
58
|
-
| `references/materials.md` | study-pack-builder | Per-topic revision material generation (summaries, flashcards, assessments) |
|
|
52
|
+
| `skills/interactive-tutor/references/assessment.md` | interactive-tutor | Prior knowledge assessment and comprehension checking |
|
|
53
|
+
| `skills/interactive-tutor/references/classroom-conduct.md` | interactive-tutor | Standing rules for language, output format, and boundaries |
|
|
54
|
+
| `skills/interactive-tutor/references/teaching-modes.md` | interactive-tutor | Four teaching approaches (Socratic, Direct, Guided Discovery, Adaptive) |
|
|
55
|
+
| `skills/lesson-planner/references/context-gathering.md` | lesson-planner | Student context fields and teaching mode descriptions |
|
|
56
|
+
| `skills/lesson-planner/references/plan-structure.md` | lesson-planner | Lesson plan format, content requirements, and quality standards |
|
|
57
|
+
| `skills/study-pack-builder/references/disaggregation.md` | study-pack-builder | Building topic hierarchies from knowledge base content |
|
|
58
|
+
| `skills/study-pack-builder/references/materials.md` | study-pack-builder | Per-topic revision material generation (summaries, flashcards, assessments) |
|
|
@@ -82,7 +82,7 @@ Select a font stack with clear hierarchy:
|
|
|
82
82
|
- UI/Caption: Small text, labels, captions
|
|
83
83
|
- Accent: Optional — a contrasting typeface for pull quotes or callouts
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Recommend proven font pairings, size scales, and font personalities that match the brand's voice and audience before making recommendations.
|
|
86
86
|
|
|
87
87
|
**Rules to follow:**
|
|
88
88
|
- Recommend Google Fonts or Adobe Fonts (free/accessible)
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# admin-can-enumerate.test.sh
|
|
3
|
-
#
|
|
4
|
-
# Acceptance gate for Task 579. Verifies that a fresh admin session
|
|
5
|
-
# locates a file under <accountDir>/sites/ on the FIRST enumeration
|
|
6
|
-
# attempt — no slug guessing, no `EISDIR`, no false-empty 200s, no
|
|
7
|
-
# "Log directory does not exist" against a path that resolves
|
|
8
|
-
# elsewhere. The gate runs from the installer post-install step against
|
|
9
|
-
# the freshly-deployed payload, before systemd services are restarted
|
|
10
|
-
# into it. A non-zero exit aborts the deploy and the old payload keeps
|
|
11
|
-
# running.
|
|
12
|
-
#
|
|
13
|
-
# Required environment:
|
|
14
|
-
# CLAUDE_CONFIG_DIR per-brand .claude directory (e.g. ~/.realagent-code/.claude)
|
|
15
|
-
# INSTALL_DIR the brand install root (e.g. ~/realagent-code)
|
|
16
|
-
#
|
|
17
|
-
# Exit codes:
|
|
18
|
-
# 0 pass — admin enumerated and emitted the seeded path within budget
|
|
19
|
-
# 1 fail — one or more assertions tripped (see stderr for the line)
|
|
20
|
-
# 2 setup error — could not seed test account or could not invoke claude
|
|
21
|
-
|
|
22
|
-
set -euo pipefail
|
|
23
|
-
|
|
24
|
-
CONFIG_DIR="${CLAUDE_CONFIG_DIR:?CLAUDE_CONFIG_DIR not set}"
|
|
25
|
-
INSTALL_DIR="${INSTALL_DIR:?INSTALL_DIR not set}"
|
|
26
|
-
|
|
27
|
-
if ! command -v claude >/dev/null 2>&1; then
|
|
28
|
-
echo "[admin-enumerate-gate] setup-error reason=claude-not-on-path" >&2
|
|
29
|
-
exit 2
|
|
30
|
-
fi
|
|
31
|
-
if ! command -v jq >/dev/null 2>&1; then
|
|
32
|
-
echo "[admin-enumerate-gate] setup-error reason=jq-not-on-path" >&2
|
|
33
|
-
exit 2
|
|
34
|
-
fi
|
|
35
|
-
|
|
36
|
-
ACCOUNTS_DIR="$INSTALL_DIR/data/accounts"
|
|
37
|
-
TEST_ACCT_ID="enumerate-gate-$$"
|
|
38
|
-
TEST_ACCT_DIR="$ACCOUNTS_DIR/$TEST_ACCT_ID"
|
|
39
|
-
JSONL_OUT=$(mktemp)
|
|
40
|
-
EXIT=0
|
|
41
|
-
|
|
42
|
-
cleanup() {
|
|
43
|
-
rm -rf "$TEST_ACCT_DIR"
|
|
44
|
-
if [ "$EXIT" -eq 0 ]; then
|
|
45
|
-
rm -f "$JSONL_OUT"
|
|
46
|
-
else
|
|
47
|
-
echo "[admin-enumerate-gate] evidence-jsonl=$JSONL_OUT" >&2
|
|
48
|
-
fi
|
|
49
|
-
}
|
|
50
|
-
trap cleanup EXIT
|
|
51
|
-
|
|
52
|
-
# Seed: one target site + 5 decoys. Target index.html is the file the
|
|
53
|
-
# agent must locate and emit the absolute path for.
|
|
54
|
-
mkdir -p "$TEST_ACCT_DIR/sites/exp-uk-got-talent"
|
|
55
|
-
echo "<html><body>eXp Has Got Talent</body></html>" > "$TEST_ACCT_DIR/sites/exp-uk-got-talent/index.html"
|
|
56
|
-
for decoy in alpha-corp-careers beta-properties gamma-events delta-news epsilon-portal; do
|
|
57
|
-
mkdir -p "$TEST_ACCT_DIR/sites/$decoy"
|
|
58
|
-
echo "<html>decoy</html>" > "$TEST_ACCT_DIR/sites/$decoy/index.html"
|
|
59
|
-
done
|
|
60
|
-
|
|
61
|
-
TARGET_PATH="$TEST_ACCT_DIR/sites/exp-uk-got-talent/index.html"
|
|
62
|
-
|
|
63
|
-
PROMPT="find the eXp Has Got Talent site in this account and print the absolute path to its index.html"
|
|
64
|
-
|
|
65
|
-
# Spawn claude headless against the test account. ACCOUNT_ID + the
|
|
66
|
-
# MAXY_SESSION_ROLE/PLATFORM_ROOT env are what the platform MCPs read
|
|
67
|
-
# to scope themselves; PLATFORM_ROOT resolves the on-disk payload that
|
|
68
|
-
# was just deployed. JSONL goes to a file we parse below.
|
|
69
|
-
ACCOUNT_ID="$TEST_ACCT_ID" \
|
|
70
|
-
MAXY_SESSION_ROLE="admin" \
|
|
71
|
-
PLATFORM_ROOT="$INSTALL_DIR/platform" \
|
|
72
|
-
CLAUDE_CONFIG_DIR="$CONFIG_DIR" \
|
|
73
|
-
claude -p "$PROMPT" --output-format=stream-json --verbose \
|
|
74
|
-
> "$JSONL_OUT" 2>&1 \
|
|
75
|
-
|| { echo "[admin-enumerate-gate] setup-error reason=claude-spawn-failed exit=$?" >&2; EXIT=2; exit 2; }
|
|
76
|
-
|
|
77
|
-
fail() {
|
|
78
|
-
echo "[admin-enumerate-gate] FAIL $1" >&2
|
|
79
|
-
EXIT=1
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
# Assertion 1: the target path appears verbatim in some assistant turn
|
|
83
|
-
# or tool-use input. Either as the agent's final message or as an arg
|
|
84
|
-
# passed to a tool.
|
|
85
|
-
if ! grep -F -q "$TARGET_PATH" "$JSONL_OUT"; then
|
|
86
|
-
fail "correct-path-not-emitted target=$TARGET_PATH"
|
|
87
|
-
fi
|
|
88
|
-
|
|
89
|
-
# Assertion 2: zero forbidden error patterns from the surfaces this
|
|
90
|
-
# task fixed.
|
|
91
|
-
FORBIDDEN_HITS=$(grep -E -c "EISDIR|Log directory does not exist|Plugin file not found|page has no readable text content" "$JSONL_OUT" || true)
|
|
92
|
-
if [ "${FORBIDDEN_HITS:-0}" -gt 0 ]; then
|
|
93
|
-
fail "forbidden-error-patterns=$FORBIDDEN_HITS"
|
|
94
|
-
fi
|
|
95
|
-
|
|
96
|
-
# Assertion 3: total tool-use calls from the user prompt to the
|
|
97
|
-
# correct-path emission are at most 3. The earlier failing run issued
|
|
98
|
-
# ~15.
|
|
99
|
-
TOOL_USE_COUNT=$(jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | .name' < "$JSONL_OUT" 2>/dev/null | wc -l | tr -d ' ' || echo 0)
|
|
100
|
-
if [ "${TOOL_USE_COUNT:-0}" -gt 3 ]; then
|
|
101
|
-
fail "tool-use-budget-exceeded count=$TOOL_USE_COUNT cap=3"
|
|
102
|
-
fi
|
|
103
|
-
|
|
104
|
-
# Assertion 4: the FIRST disk-listing tool call returns an enumeration
|
|
105
|
-
# of <accountDir>/sites/. "Disk-listing" means LS, Glob, or Bash with
|
|
106
|
-
# `ls`. We accept any of those as the first relevant call; what we
|
|
107
|
-
# reject is a Read against a directory path.
|
|
108
|
-
FIRST_DIR_TOOL=$(jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | {name, input} | @json' < "$JSONL_OUT" 2>/dev/null \
|
|
109
|
-
| jq -r 'select(.name=="LS" or .name=="Glob" or (.name=="Bash" and (.input.command // "" | test("\\bls\\b")))) | .name' \
|
|
110
|
-
| head -1)
|
|
111
|
-
if [ -z "$FIRST_DIR_TOOL" ]; then
|
|
112
|
-
fail "no-disk-listing-tool-call-emitted"
|
|
113
|
-
fi
|
|
114
|
-
|
|
115
|
-
# Assertion 5: no Read tool call against a path under <accountDir>/sites/
|
|
116
|
-
# that is a directory (would have produced EISDIR pre-fix; should not
|
|
117
|
-
# even be attempted now).
|
|
118
|
-
BAD_READ=$(jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use" and .name=="Read") | .input.file_path // ""' < "$JSONL_OUT" 2>/dev/null \
|
|
119
|
-
| while read -r p; do
|
|
120
|
-
[ -n "$p" ] && [ -d "$p" ] && echo "$p"
|
|
121
|
-
done | head -1)
|
|
122
|
-
if [ -n "$BAD_READ" ]; then
|
|
123
|
-
fail "read-against-directory path=$BAD_READ"
|
|
124
|
-
fi
|
|
125
|
-
|
|
126
|
-
if [ "$EXIT" -eq 0 ]; then
|
|
127
|
-
echo "[admin-enumerate-gate] PASS target=$TARGET_PATH tool-use-count=$TOOL_USE_COUNT first-dir-tool=$FIRST_DIR_TOOL"
|
|
128
|
-
fi
|
|
129
|
-
exit "$EXIT"
|