@rubytech/create-maxy-code 0.1.223 → 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.
@@ -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";
@@ -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...");
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.223",
3
+ "version": "0.1.224",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -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:cabadf29cd1f98c47b4f93c23339ee577ce7bb41092d085d01268b947165a1d5
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 (inherited from business-assistant — booking protocol, geographic clustering, briefings)
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 (HOOKS ONLY)
47
+ # 2. Write project-level .claude/settings.json (permissions + hooks)
48
48
  # ------------------------------------------------------------------
49
- # Task 453: no `permissions.allow` block. The previous short-prefix
50
- # allowlist (mcp__memory__*, mcp__contacts__*, …) never matched the
51
- # plugin-manifest long-prefix tool names Claude Code actually emits
52
- # (mcp__plugin_memory_memory__…), so every Task-subagent plugin call
53
- # was denied at use-time. Permission enforcement comes from each agent's
54
- # frontmatter `tools:` list (native CC permission surface) and from the
55
- # PreToolUse approval hooks below for review-gated tools.
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
- Read `references/typography-reference.md` for proven pairings, size scales, and font personality guidance before making recommendations.
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)