create-claude-cabinet 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +11 -0
  2. package/lib/cli.js +10 -3
  3. package/lib/mux-setup.js +4 -0
  4. package/lib/settings-merge.js +40 -1
  5. package/package.json +1 -1
  6. package/templates/cabinet/_cabinet-member-template.md +6 -20
  7. package/templates/cabinet/watchtower-contracts.md +75 -3
  8. package/templates/engagement-server/Dockerfile +2 -0
  9. package/templates/engagement-server/server.mjs +73 -21
  10. package/templates/mux/bin/mux +196 -10
  11. package/templates/mux/config/__pycache__/muxlib.cpython-314.pyc +0 -0
  12. package/templates/mux/config/help.txt +5 -0
  13. package/templates/mux/config/mux-server.py +5 -2
  14. package/templates/mux/config/muxlib.py +31 -1
  15. package/templates/mux/config/worktree-cleanup.sh +86 -0
  16. package/templates/mux/config/worktree-health-popup.sh +23 -0
  17. package/templates/mux/config/worktree-session-health.sh +105 -0
  18. package/templates/rules/maintainability.md +92 -0
  19. package/templates/rules/memory-capture.md +4 -2
  20. package/templates/scripts/watchtower-build-context.mjs +95 -7
  21. package/templates/scripts/watchtower-lib.mjs +41 -1
  22. package/templates/scripts/watchtower-queue.mjs +13 -9
  23. package/templates/scripts/watchtower-ring1.mjs +248 -14
  24. package/templates/scripts/watchtower-ring2.mjs +280 -20
  25. package/templates/scripts/watchtower-ring3-close.mjs +424 -105
  26. package/templates/scripts/watchtower-status.sh +260 -0
  27. package/templates/scripts/watchtower-validate.mjs +3 -3
  28. package/templates/skills/briefing/SKILL.md +46 -7
  29. package/templates/skills/cabinet-accessibility/SKILL.md +58 -223
  30. package/templates/skills/cabinet-anthropic-insider/SKILL.md +63 -296
  31. package/templates/skills/cabinet-anti-confirmation/SKILL.md +36 -152
  32. package/templates/skills/cabinet-architecture/SKILL.md +57 -265
  33. package/templates/skills/cabinet-automation/SKILL.md +75 -398
  34. package/templates/skills/cabinet-boundary-man/SKILL.md +56 -194
  35. package/templates/skills/cabinet-cc-health/SKILL.md +62 -462
  36. package/templates/skills/cabinet-cc-health/migration-reference.md +46 -0
  37. package/templates/skills/cabinet-data-integrity/SKILL.md +51 -142
  38. package/templates/skills/cabinet-debugger/SKILL.md +65 -209
  39. package/templates/skills/cabinet-elegance/SKILL.md +32 -229
  40. package/templates/skills/cabinet-framework-quality/SKILL.md +76 -387
  41. package/templates/skills/cabinet-goal-alignment/SKILL.md +62 -218
  42. package/templates/skills/cabinet-historian/SKILL.md +74 -320
  43. package/templates/skills/cabinet-information-design/SKILL.md +87 -432
  44. package/templates/skills/cabinet-interactive-storyteller/SKILL.md +75 -307
  45. package/templates/skills/cabinet-mantine-quality/SKILL.md +40 -293
  46. package/templates/skills/cabinet-narrative-architect/SKILL.md +65 -254
  47. package/templates/skills/cabinet-organized-mind/SKILL.md +88 -340
  48. package/templates/skills/cabinet-process-therapist/SKILL.md +68 -233
  49. package/templates/skills/cabinet-qa/SKILL.md +55 -195
  50. package/templates/skills/cabinet-record-keeper/SKILL.md +57 -170
  51. package/templates/skills/cabinet-roster-check/SKILL.md +72 -300
  52. package/templates/skills/cabinet-security/SKILL.md +56 -211
  53. package/templates/skills/cabinet-small-screen/SKILL.md +36 -138
  54. package/templates/skills/cabinet-speed-freak/SKILL.md +34 -198
  55. package/templates/skills/cabinet-system-advocate/SKILL.md +60 -170
  56. package/templates/skills/cabinet-technical-debt/SKILL.md +34 -176
  57. package/templates/skills/cabinet-ui-experimentalist/SKILL.md +65 -231
  58. package/templates/skills/cabinet-usability/SKILL.md +57 -175
  59. package/templates/skills/cabinet-user-advocate/SKILL.md +67 -290
  60. package/templates/skills/cabinet-vision/SKILL.md +60 -224
  61. package/templates/skills/cabinet-workflow-cop/SKILL.md +61 -226
  62. package/templates/skills/collab-client/SKILL.md +30 -0
  63. package/templates/skills/collab-consultant/SKILL.md +146 -2
  64. package/templates/skills/decisions/SKILL.md +7 -158
  65. package/templates/skills/dx-feedback/SKILL.md +125 -0
  66. package/templates/skills/inbox/SKILL.md +181 -0
  67. package/templates/skills/investigate/SKILL.md +2 -2
  68. package/templates/skills/orient/phases/dx-captures.md +16 -17
  69. package/templates/skills/plan/phases/verify-plan.md +3 -3
  70. package/templates/skills/watchtower/SKILL.md +3 -2
  71. package/templates/watchtower/queue/items/item.json.schema +3 -3
package/README.md CHANGED
@@ -221,6 +221,17 @@ the listed modules to what's already there, it doesn't replace your
221
221
  module set. Safe to run on a mature project without losing
222
222
  customization. You can pass multiple modules: `--modules verify,audit`.
223
223
 
224
+ ### Opt-in Modules
225
+
226
+ | Module | What it does |
227
+ |--------|-------------|
228
+ | **verify** | Cucumber + Playwright walkthrough verification harness |
229
+ | **site-audit** | 14-check deployed-site quality audit with HTML reports |
230
+ | **engagement** | Client engagement management — packets, billing, feedback loops |
231
+ | **engagement-server** | Central multi-engagement API server (Railway/Fly deploy) |
232
+ | **watchtower** | Continuous background state management replacing orient/debrief |
233
+ | **mux** | Multi-project terminal manager — desks, auto-worktrees with shared identity, trail logging, DX captures, portal color-switching |
234
+
224
235
  ## CLI Options
225
236
 
226
237
  ```
package/lib/cli.js CHANGED
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const crypto = require('crypto');
6
6
  const { copyTemplates } = require('./copy');
7
- const { mergeSettings, healUserSettings, mergeWatchtowerHooks } = require('./settings-merge');
7
+ const { mergeSettings, healUserSettings, mergeWatchtowerHooks, mergeMuxHooks } = require('./settings-merge');
8
8
  const { create: createMetadata, read: readMetadata } = require('./metadata');
9
9
  const { setupDb } = require('./db-setup');
10
10
  const { setupVerifyRuntime } = require('./verify-setup');
@@ -496,7 +496,7 @@ const MODULES = {
496
496
  mandatory: false,
497
497
  default: true,
498
498
  lean: false,
499
- templates: ['rules/enforcement-pipeline.md', 'memory/patterns/_pattern-template.md', 'memory/patterns/pattern-intelligence-first.md'],
499
+ templates: ['rules/enforcement-pipeline.md', 'rules/maintainability.md', 'memory/patterns/_pattern-template.md', 'memory/patterns/pattern-intelligence-first.md'],
500
500
  },
501
501
  'memory': {
502
502
  name: 'Built-In Memory (cc-remember + reader + validator)',
@@ -623,7 +623,7 @@ const MODULES = {
623
623
  },
624
624
  watchtower: {
625
625
  name: 'Watchtower (continuous background state)',
626
- description: 'Replaces orient/debrief with continuous background processing. Three rings (mechanical cron, Claude intelligence, session-aware), ambient state injection via SessionStart hook, and an asynchronous decision queue. Sessions start informed with minimal context cost.',
626
+ description: 'Replaces orient/debrief with continuous background processing. Three rings (mechanical cron, Claude intelligence, session-aware), ambient state injection via SessionStart hook, and an inbox for extracted knowledge and signals. Sessions start informed with minimal context cost.',
627
627
  mandatory: false,
628
628
  default: false,
629
629
  lean: false,
@@ -632,6 +632,7 @@ const MODULES = {
632
632
  'cabinet/watchtower-contracts.md',
633
633
  'scripts/watchtower-lib.mjs',
634
634
  'scripts/watchtower-queue.mjs',
635
+ 'skills/inbox',
635
636
  'skills/decisions',
636
637
  'hooks/watchtower-session-start.sh',
637
638
  'scripts/watchtower-build-context.mjs',
@@ -651,6 +652,7 @@ const MODULES = {
651
652
  'watchtower/watchtower-ring2-slow.timer',
652
653
  'hooks/watchtower-session-end.sh',
653
654
  'scripts/watchtower-ring3-close.mjs',
655
+ 'scripts/watchtower-status.sh',
654
656
  'skills/briefing',
655
657
  ],
656
658
  },
@@ -1285,6 +1287,11 @@ async function run() {
1285
1287
  mergeWatchtowerHooks(settingsPath);
1286
1288
  console.log(' ⚙️ Registered watchtower SessionStart/SessionEnd hooks');
1287
1289
  }
1290
+
1291
+ if (selectedModules.includes('mux')) {
1292
+ mergeMuxHooks(settingsPath);
1293
+ console.log(' ⚙️ Registered mux worktree health SessionStart hook');
1294
+ }
1288
1295
  }
1289
1296
 
1290
1297
  // --- Heal user-level ~/.claude/settings.json ---
package/lib/mux-setup.js CHANGED
@@ -42,6 +42,9 @@ const MANAGED_FILES = [
42
42
  { src: 'config/help.txt', dest: path.join(os.homedir(), '.config', 'mux', 'help.txt') },
43
43
  { src: 'config/_mux', dest: path.join(os.homedir(), '.config', 'mux', '_mux') },
44
44
  { src: 'config/mux.bash', dest: path.join(os.homedir(), '.config', 'mux', 'mux.bash') },
45
+ { src: 'config/worktree-session-health.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-session-health.sh'), mode: 0o755 },
46
+ { src: 'config/worktree-health-popup.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-health-popup.sh'), mode: 0o755 },
47
+ { src: 'config/worktree-cleanup.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-cleanup.sh'), mode: 0o755 },
45
48
  ];
46
49
 
47
50
  const DATA_DIRS = [
@@ -50,6 +53,7 @@ const DATA_DIRS = [
50
53
  path.join(os.homedir(), '.config', 'mux', 'notes'),
51
54
  path.join(os.homedir(), '.config', 'mux', 'dx'),
52
55
  path.join(os.homedir(), '.config', 'mux', 'pending-prompts'),
56
+ path.join(os.homedir(), '.local', 'share', 'mux', 'wt-health'),
53
57
  ];
54
58
 
55
59
  function sha256(content) {
@@ -107,6 +107,20 @@ const WATCHTOWER_HOOKS = {
107
107
  ],
108
108
  };
109
109
 
110
+ const MUX_HOOKS = {
111
+ SessionStart: [
112
+ {
113
+ matcher: '',
114
+ hooks: [
115
+ {
116
+ type: 'command',
117
+ command: '$HOME/.config/mux/worktree-session-health.sh',
118
+ },
119
+ ],
120
+ },
121
+ ],
122
+ };
123
+
110
124
  // Legacy hook script names that should be stripped on any merge.
111
125
  // Centralizes cleanup so a user who skips --migrate-memory but runs
112
126
  // any other CC operation still gets omega-era hooks pruned.
@@ -264,4 +278,29 @@ function mergeWatchtowerHooks(settingsPath) {
264
278
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
265
279
  }
266
280
 
267
- module.exports = { mergeSettings, healUserSettings, mergeWatchtowerHooks, DEFAULT_HOOKS, WATCHTOWER_HOOKS, LEGACY_HOOK_COMMANDS };
281
+ function mergeMuxHooks(settingsPath) {
282
+ if (!fs.existsSync(settingsPath)) return;
283
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
284
+ if (!settings.hooks) settings.hooks = {};
285
+
286
+ for (const [event, newHooks] of Object.entries(MUX_HOOKS)) {
287
+ if (!settings.hooks[event]) {
288
+ settings.hooks[event] = newHooks;
289
+ } else {
290
+ for (const newHook of newHooks) {
291
+ const hookKey = h => h.command || h.prompt || '';
292
+ const existingKeys = settings.hooks[event].flatMap(h =>
293
+ h.hooks.map(hh => hookKey(hh))
294
+ );
295
+ const newKeys = newHook.hooks.map(h => hookKey(h));
296
+ if (!newKeys.every(k => existingKeys.includes(k))) {
297
+ settings.hooks[event].push(newHook);
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
304
+ }
305
+
306
+ module.exports = { mergeSettings, healUserSettings, mergeWatchtowerHooks, mergeMuxHooks, DEFAULT_HOOKS, WATCHTOWER_HOOKS, MUX_HOOKS, LEGACY_HOOK_COMMANDS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -125,33 +125,19 @@ to anchor the boundaries.
125
125
 
126
126
  ### 7. Historically Problematic Patterns
127
127
 
128
- Two-file overlay:
129
-
130
128
  ```markdown
131
129
  ## Historically Problematic Patterns
132
130
 
133
- Two sources read both and merge at runtime:
134
-
135
- 1. **This section** (upstream, CC-owned) — universal patterns that apply to
136
- any project. Grows when consuming projects promote recurring findings
137
- via field-feedback.
138
- 2. **`patterns-project.md`** in this skill's directory — project-specific
139
- patterns discovered during audits of this particular project. Project-
140
- owned, never overwritten by CC upgrades.
141
-
142
- If `patterns-project.md` exists, read it alongside this section. Both
143
- inform your analysis equally.
144
-
145
- **How patterns get here:** A consuming project's audit finds a real issue.
146
- If the same pattern recurs across projects, it gets promoted upstream via
147
- field-feedback. The CC maintainer adds it to this section. Project-specific
148
- patterns that don't generalize stay in `patterns-project.md`.
131
+ Read `patterns-project.md` in this skill directory for project-specific
132
+ patterns from prior audits. Apply alongside universal patterns below.
149
133
 
150
134
  <!-- Universal patterns below this line -->
151
135
  ```
152
136
 
153
- This section starts empty for new members. It accumulates from real
154
- findings over time — never pre-fill with hypothetical patterns.
137
+ This section starts empty for new members. Universal patterns accumulate
138
+ from real field-feedback findings over time — never pre-fill with
139
+ hypothetical patterns. Project-specific patterns live in
140
+ `patterns-project.md` (project-owned, never overwritten by CC upgrades).
155
141
 
156
142
  ## Optional Sections
157
143
 
@@ -17,12 +17,12 @@ fs.renameSync(tmp, filePath);
17
17
 
18
18
  ## No-Index Convention
19
19
 
20
- The decision queue uses directory listing, not an index file. To list
20
+ The inbox queue uses directory listing, not an index file. To list
21
21
  pending items, read `queue/items/`, open each `.json`, filter by
22
22
  status. No manifest, no index.json.
23
23
 
24
24
  Rationale: one fewer file to keep consistent. Directory listing is
25
- O(n) but n is small (decision queues rarely exceed 50 items). If
25
+ O(n) but n is small (inbox items rarely exceed 50 items). If
26
26
  listing exceeds 2 seconds or 500 items, revisit via `act:2b638b02`.
27
27
 
28
28
  ## Schema Versioning
@@ -70,7 +70,7 @@ standard files:
70
70
  | `memory-refs.md` | Ring 2 fast | Relevant memory entries |
71
71
  | `options-analysis.md` | Ring 2 fast | Pros/cons with evidence |
72
72
 
73
- `/decisions` reads these when `enrichment_status` is `"complete"`.
73
+ `/inbox` reads these when `enrichment_status` is `"complete"`.
74
74
  Missing files degrade gracefully (null in the read result).
75
75
 
76
76
  ## Deferred Schemas
@@ -85,3 +85,75 @@ their owning plan ships:
85
85
  | `hooks/<ring>-<phase>.d/` | Plan 9 | Lifecycle hooks |
86
86
  | `logs/<ring>.log` | Plan 4 | Ring 1 runner |
87
87
  | `lock/<ring>.pid` | Plan 4 | Ring 1 runner |
88
+
89
+ ## Ring 4 — Periodic Truth Reconciliation (design phase)
90
+
91
+ **Status: concept. Needs design session before implementation.**
92
+
93
+ Rings 1–3 operate on individual signals: individual files (R1),
94
+ individual patterns (R2), individual sessions (R3). Nothing catches
95
+ **cumulative drift** — the slow rot where twenty sessions each move
96
+ reality a little further from what documents claim, but no single
97
+ session is the tipping point.
98
+
99
+ Ring 4 is the answer to: "Is what we wrote still true?"
100
+
101
+ ### The problem it solves
102
+
103
+ The maginnis project's architecture briefing said "Layer 4 Not built"
104
+ and "tech stack TBD" for three weeks after the platform shipped with
105
+ a Rails 8 app, Mantine frontend, 421 RSpec specs, and staging on
106
+ Railway. No individual session caused the staleness. No individual
107
+ session's debrief would flag it. The drift was invisible until
108
+ someone tried to use the briefing for a real audit and found it
109
+ described a project that no longer existed.
110
+
111
+ This class of problem includes:
112
+ - **Briefing-to-codebase drift** — claims about architecture, tech
113
+ stack, file layout, or project state that no longer match reality
114
+ - **Memory-to-codebase drift** — memory files that reference
115
+ functions, files, or flags that have been renamed or removed
116
+ - **CLAUDE.md drift** — sections describing architecture or
117
+ conventions that have been refactored past
118
+ - **Plan-to-reality drift** — plan files describing work that's been
119
+ done but never marked complete, or describing approaches that were
120
+ abandoned
121
+
122
+ ### Cadence and weight
123
+
124
+ Less frequent than R1–R3. Daily or weekly, not per-session or
125
+ per-minute. Heavier per run — reads real code, cross-references
126
+ document claims against codebase state, may invoke Claude for
127
+ semantic comparison. Acceptable cost because it runs rarely and
128
+ catches problems that compound silently.
129
+
130
+ ### Relationship to other rings
131
+
132
+ Following the nervous system principle (not a stack): R4 consumes
133
+ signals from R1–R3 (what changed recently, what sessions touched,
134
+ what patterns emerged) to prioritize what to reconcile. R4 produces
135
+ inbox items when drift is detected. R4 does not mutate documents —
136
+ it flags, the user triages and routes.
137
+
138
+ R3 catches "this session broke the briefing." R4 catches "the
139
+ briefing has been slowly wrong for three weeks and nobody noticed."
140
+ They're complementary, not redundant.
141
+
142
+ ### Open design questions
143
+
144
+ - **Scope per run:** Reconcile everything every time, or rotate
145
+ through documents on a schedule? Full sweep is thorough but
146
+ expensive; rotation risks missing urgent drift on off-cycle docs.
147
+ - **Drift threshold:** How wrong does a document need to be before
148
+ it's worth flagging? A missing model in the architecture briefing
149
+ is clear; a slightly outdated line count is noise.
150
+ - **Trigger vs schedule:** Pure schedule (weekly), or also triggered
151
+ when R1/R2 detect significant codebase changes (new migration,
152
+ major refactor, new dependency)?
153
+ - **Cross-project scope:** Per-project reconciliation, or also
154
+ cross-project (e.g., a CC template claiming something about
155
+ consumer behavior that's no longer true)?
156
+ - **Relationship to Ring 2 slow tier:** R2 slow already does some
157
+ staleness detection (stale work, memory hygiene). Where's the line
158
+ between R2's "is this work item stale?" and R4's "is this document
159
+ still true?" Is R4 an extension of R2 slow, or genuinely distinct?
@@ -1,5 +1,7 @@
1
1
  FROM node:20-slim
2
2
 
3
+ RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
4
+
3
5
  WORKDIR /app
4
6
 
5
7
  COPY package.json package-lock.json* ./
@@ -73,7 +73,7 @@ function hashToken(raw) {
73
73
  return createHash('sha256').update(raw).digest('hex');
74
74
  }
75
75
 
76
- function resolveToken(tokenHash) {
76
+ function resolveLocalToken(tokenHash) {
77
77
  return db.prepare(`
78
78
  SELECT u.id AS user_id, u.engagement_id, u.role, u.name AS user_name,
79
79
  e.auth_mode, e.auth_config
@@ -84,31 +84,83 @@ function resolveToken(tokenHash) {
84
84
  `).get(tokenHash);
85
85
  }
86
86
 
87
+ function getExternalEngagements() {
88
+ return db.prepare(`
89
+ SELECT id, auth_config FROM engagements
90
+ WHERE auth_mode = 'external' AND auth_config IS NOT NULL
91
+ `).all();
92
+ }
93
+
94
+ function resolveExternalUser(engagementId, email, roleMapping) {
95
+ const user = db.prepare(`
96
+ SELECT id AS user_id, engagement_id, role, name AS user_name
97
+ FROM users
98
+ WHERE engagement_id = ? AND email = ?
99
+ `).get(engagementId, email);
100
+ if (user) return user;
101
+
102
+ return null;
103
+ }
104
+
87
105
  async function authenticateRequest(req) {
88
106
  const authHeader = req.headers['authorization'];
89
107
  if (!authHeader || !authHeader.startsWith('Bearer ')) return null;
90
108
 
91
109
  const raw = authHeader.slice(7);
92
- const tokenData = resolveToken(hashToken(raw));
93
- if (!tokenData) return null;
94
110
 
95
- if (tokenData.auth_mode === 'external' && tokenData.auth_config) {
111
+ // Path 1: local token lookup (works for all auth modes)
112
+ const localData = resolveLocalToken(hashToken(raw));
113
+ if (localData && localData.auth_mode === 'local') return localData;
114
+
115
+ // Path 2: external auth — forward token to client app's validate_url
116
+ const externals = getExternalEngagements();
117
+ for (const eng of externals) {
96
118
  try {
97
- const config = JSON.parse(tokenData.auth_config);
98
- if (config.validate_url) {
99
- const resp = await fetch(config.validate_url, {
100
- headers: { 'Authorization': `Bearer ${raw}` },
101
- signal: AbortSignal.timeout(5000),
102
- });
103
- if (!resp.ok) return null;
104
- }
119
+ const config = JSON.parse(eng.auth_config);
120
+ if (!config.validate_url) continue;
121
+
122
+ const resp = await fetch(config.validate_url, {
123
+ headers: { 'Authorization': `Bearer ${raw}` },
124
+ signal: AbortSignal.timeout(5000),
125
+ });
126
+ if (!resp.ok) continue;
127
+
128
+ const identity = await resp.json();
129
+ const email = identity.email;
130
+ if (!email) continue;
131
+
132
+ // Map platform role to engagement role via role_mapping
133
+ const roleMapping = config.role_mapping || {};
134
+ const mappedRole = roleMapping[identity.role] || identity.role;
135
+
136
+ // Find matching local user by email for message attribution
137
+ const user = resolveExternalUser(eng.id, email, roleMapping);
138
+ if (user) return { ...user, auth_mode: 'external' };
139
+
140
+ // User authenticated with platform but no local user record —
141
+ // auto-create so messages are properly attributed
142
+ const userId = `usr_${randomBytes(6).toString('hex')}`;
143
+ const engRole = ['consultant', 'client'].includes(mappedRole) ? mappedRole : 'client';
144
+ db.prepare(`INSERT INTO users (id, engagement_id, name, email, role) VALUES (?, ?, ?, ?, ?)`)
145
+ .run(userId, eng.id, email.split('@')[0], email, engRole);
146
+
147
+ return {
148
+ user_id: userId,
149
+ engagement_id: eng.id,
150
+ role: engRole,
151
+ user_name: email.split('@')[0],
152
+ auth_mode: 'external',
153
+ };
105
154
  } catch (err) {
106
- console.error(`External auth failed: ${err.message}`);
107
- return { error: 'auth_backend_unavailable' };
155
+ console.error(`External auth failed for engagement ${eng.id}: ${err.message}`);
108
156
  }
109
157
  }
110
158
 
111
- return tokenData;
159
+ // Path 3: local token in an external-auth engagement (consultant-side
160
+ // tokens may still be locally managed)
161
+ if (localData) return localData;
162
+
163
+ return null;
112
164
  }
113
165
 
114
166
  // ---------------------------------------------------------------------------
@@ -278,18 +330,18 @@ const server = createServer(async (req, res) => {
278
330
  const path = url.pathname;
279
331
  const method = req.method;
280
332
 
333
+ // Health check — no auth, before HTTPS enforcement (Railway internal probes lack x-forwarded-proto)
334
+ if (path === '/health' && method === 'GET') {
335
+ logRequest(req, 200, null);
336
+ return json(res, 200, { ok: true, version: SCHEMA_VERSION });
337
+ }
338
+
281
339
  // HTTPS enforcement (Railway terminates TLS)
282
340
  if (process.env.RAILWAY_ENVIRONMENT && req.headers['x-forwarded-proto'] !== 'https') {
283
341
  logRequest(req, 421, null);
284
342
  return json(res, 421, { error: 'https_required' });
285
343
  }
286
344
 
287
- // Health check — no auth
288
- if (path === '/health' && method === 'GET') {
289
- logRequest(req, 200, null);
290
- return json(res, 200, { ok: true, version: SCHEMA_VERSION });
291
- }
292
-
293
345
  // All other routes require auth
294
346
  const authHeader = req.headers['authorization'];
295
347
  if (!authHeader || !authHeader.startsWith('Bearer ')) {