Enforces the SN6 Resources repo rules: hosting-only deploys from a pushed tree, releases only through release.mjs, no force-push to main, HTTPS remote, no…

I put this together in July 2026, after comp, from everything we did in SN5 — the Drive, the full #composites and #purchasing history, and the manufacturer datasheets for the stuff we actually buy. It's the handoff I wish I'd gotten: what went wrong and why, standards so the answers stop living in people's heads, and a work-order system so we can actually trace what we built. Everything went through staged reviews (G0–G4) against a reviewer agent loaded with our constraints and SN5 history before it landed here.
— Simon
New lead? Start with HANDOFF.md. It explains how to run and care for everything in here.
| Folder | Contents | Start with |
|---|---|---|
00 Agent/ | The "simon" reviewer-agent definition. Archival copy; the live one is at composites_programs/.claude/agents/simon.md | |
01 Pain Points and Improvements/ | The SN5 season review: what went well, 10 major problems with root-cause analyses, traceability to the fixes | the .docx |
02 CS Standards/ | 14 numbered composites standards (CS-000 to CS-013). The markdown in src/ is the canonical text; the .docx files are built output. Figures are SVG in src/figures/ with rendered PNGs beside them | CS-INDEX |
03 Datasheets/ | 25 manufacturer TDS/SDS PDFs for the products we actually use | INDEX.md |
04 Printables/ | Shop reference sheets meant to be printed: resin ratios, flowcharts, checklists | README.md |
05 Design System/ | The app's visual language as a reusable system: tokens, component CSS, a living style guide | styleguide.html |
06 Composites App/ | The composites work-order app, live at feb-composites.web.app | app/README.md |
07 CFD PDF Viewer/ | The desktop original of the CFD viewer, kept buildable; the hosted version lives in 08 | README.md |
08 CFD Sims Dashboard/ | The CFD app, live at feb-cfd.web.app on its own Firebase project: a dashboard of every report with its downforce and drag by design point, saved views, and the report viewer from 07, all in the composites app's shell | README.md |
09 Website/ | The public team website, built on the design system. Not deployed; its README has the state of it | README.md |
10 Fusion Add-in/ | FEBPlanStock/, a Fusion add-in that runs the stack planner from inside Fusion and draws the board layers over the mold, plus the feasibility study and spikes that led to it. Install it from the latest release: download the zip, unzip, double-click the installer | 10 Fusion Add-in/README.md |
tools/ | Everything that builds and checks the rest: the docx builder, the generators, the servers, and 24 test suites | README.md |
SETUP.md is the full walkthrough for a new machine, macOS or Windows: what to install, how to verify it, how to run the suite, how to deploy, and the platform traps that cost a day each if you meet them cold.
The short version. You need Node for the app tooling, Playwright for the browser tests, the Firebase CLI and a JDK for the rules tests and deploys, and Python 3 only if you are editing the standards. Three commands cover most days, all run from this folder:
node tools/serve_populated.mjs --port 8791 # the app locally, seeded, no Firebase
node tools/test_app.mjs # the core logic suite
node tools/test_designsystem.mjs # CSS drift check, ~1 second
The live app needs no setup at all: it is at https://feb-composites.web.app, and access is controlled by the roster inside it.
The repo was renamed from feb-composites-applications to feb-engineering-apps on 2026-09-02, when the CFD dashboard joined it. The old URL redirects. The folders were renumbered the same day: reference material first, the shared design system, then the four apps together, with 03 App/ becoming 06 Composites App/. The two apps are separate Firebase projects on purpose: feb-composites and feb-cfd, each deployed from its own folder, so a deploy of one can never touch the other's rules or data.
Anyone can open it and press View as guest: the whole app, read-only, with no account and nothing to ask a lead for. Editing needs a name, because every buy-off carries one.
06 Composites App/app/ is the team's shared workspace for a season, running on Firebase. Sign-up is self-serve: a name, a username and a password get you in as a member. It updates live for everyone and works on phones and tablets as well as desktop. The full manual lives in 06 Composites App/app/README.md and the architecture in 06 Composites App/app/DESIGN-NOTES.md; this is the short tour.

Twelve tabs, grouped in the sidebar by who is asking:


Cross-links are everywhere; click a chip to jump to the related record. ⌘K searches everything. Light and dark themes follow the system setting, and printing always comes out black-on-white. Access is enforced server-side by firestore.rules. Signing up creates your own roster entry as a member and nothing more: you cannot name your own role, and only a lead can grant one or remove anybody.
Every physical thing gets a 4 × 1 inch label: the ID, the fact that actually identifies it, and a QR code. A plain phone camera opens a public nameplate saying what the object is, its stage and where it lives, no account and no install; names, costs and files stay behind the roster. The in-app Scan button makes a move two scans (the object, then the shelf), works on iPhones through a lazy-loaded wasm decoder, and reads the UC EH&S barcode tags on chemical containers, so the campus sticker is the container's identity. The cure buy-off captures which fabric roll and which resin and hardener lots went in, and "I don't know" is a recorded answer. The bulk builder prints Avery sheets with a 100 mm calibration bar, because browsers silently scale.


The old single-file 06 Composites App/work-orders.html stays as an offline backup and archive viewer. It opens any exported JSON with no server at all. Don't delete it.
07 CFD PDF Viewer/ compares Fluent CFD reports without opening two PDFs side by side and hunting for the same plot in each. Load two or more reports: Pages scrolls them together, Panels pulls one named plot out of every report cropped identically, Overlay blends or per-pixel-diffs two, Summary tables the solver settings with changes highlighted, and Search covers everything open. Desktop (Electron) and web are the same code; its README.md has a two-command way to try it on the sample reports.

Pain Points and CS Standards (01, 02) are where the app's rules come from: 10 root-caused SN5 problems, each mapped to a numbered standard that fixes it. CS-INDEX is the lookup and python3 tools/check_traceability.py audits the mapping. Every quantitative claim cites a datasheet in 03 Datasheets/ or a recorded team measurement, and every standard ships "Draft, pending Lead signature" until someone signs the approval table.
Datasheets and Printables (04, 05) are reference material: manufacturer TDS/SDS PDFs chosen from actual purchase history, and shop-floor sheets meant to be printed.
Design System (06) is the app's visual language pulled out into something reusable: tokens, component styles, and a living style guide in light and dark. The app remains the source of truth; tools/test_designsystem.mjs keeps the two from drifting apart.
Open items (need a human): move the feb-composites Firebase project to a team Google account (or add the next lead as an owner) so it survives handoff; confirm the ShopSabre's exact model against CS-005 §5; field-verify the CS-011 storage map at RFS; sign the approval tables. HANDOFF.md carries the full list.
Maintenance: the standards are edited as Google Docs (02 CS Standards/GOOGLE-DOCS.md has the folder and per-doc links); edits sync back into 02 CS Standards/src/ with a revision bump, then rebuild with tools/.venv/bin/python tools/build_docx.py --all, then python3 tools/gen_docs_manifest.py and python3 tools/check_traceability.py. Figures are edited as SVG in src/figures/ and re-rendered with node tools/render_figures.mjs before the rebuild. Regenerate retro work orders only if the source data was wrong.
The full inventory, what each suite covers, which need Playwright or the Firebase emulator, and the hard-won lessons behind the browser tests all live in tools/README.md. The short version:
node tools/test_app.mjs # app logic, no browser, run this first
node tools/test_designsystem.mjs # CSS drift between app and 06, ~1s
node tools/test_appui.mjs # every tab, four widths, two themes, measured
node tools/test_detailui.mjs # the same with records open and fields full
plus suites for the mold slicer and packer, the drawings, printing on phones, labels and QR codes, scanning, the public scan page, the sanitizer, safe-area insets, the website, and the three Firebase rules files. Before shipping anything visual, run the matching browser suite and look at the screenshots it can write with --shots; the tests measure, but only eyes catch "unreadable". After a UI change, node tools/make_mockups.mjs regenerates the annotated screenshots in this and the other READMEs.
One quirk worth knowing: the git root is this folder rather than 06 Composites App/, because the scripts in tools/ resolve their paths relative to here. firebase deploy still has to run from inside 06 Composites App/.
Two Claude Code mods live in .claude/skills/ and load in every Claude session opened on this repo. They turn rules from CLAUDE.md into checks instead of things a session has to remember.
feb-guard refuses, before it runs: a bare firebase deploy; --only with anything but hosting (Simon typing "allow rules deploy" or "allow functions deploy" lifts that for one turn); a hosting deploy while 06 Composites App/ is dirty or has unpushed commits; gh release create/upload/edit/delete and addin-v* tags, since tools/release.mjs is the only release path; sed -i or an Edit/Write that changes APP_VERSION, ADDIN_VERSION or the add-in manifest version; a force-push to main; and an SSH remote.
feb-deploy-verify runs after a hosting deploy that reports success. It fetches core.js and the last commit's changed text files off feb-composites.web.app, compares them byte for byte with the commit, and tells the session plainly when they differ. Deploys made inside release.mjs are not seen by either mod; the script verifies those itself.
Their tests run with claude plugin test .claude/skills/feb-guard (and the same for feb-deploy-verify). The claude on PATH has to be a build that knows the plugin test command.
hooks/register.ts 70 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { checkBash, checkVersionEdit, isHostingDeploy, versionFile, wantsNonHosting } from './rules'
4
5// Only this project's sessions are guarded.
6const SCOPE = '/composites_programs/'
7const APP_DIR = '06 Composites App'
8
9export const register: Register = on => {
10 // Set per prompt: Simon typing "allow rules deploy" lifts the hosting-only
11 // rule for the rest of that turn and no longer.
12 let nonHostingDeploy = false
13
14 on('prompt.submit', ($, e, next) => {
15 nonHostingDeploy = wantsNonHosting(e.text)
16 return next(e)
17 })
18
19 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
20 const cwd = await $.session.cwd()
21 if (!cwd.includes(SCOPE)) return next(e)
22
23 const reason = checkBash(e.command, { nonHostingDeploy })
24 if (reason) return refuse($, reason)
25
26 if (isHostingDeploy(e.command)) {
27 const unpushed = await unpushedState($, cwd)
28 if (unpushed) return refuse($, unpushed)
29 }
30
31 return next(e)
32 })
33
34 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
35 if (!e.file_path.includes(SCOPE) || !versionFile(e.file_path)) return next(e)
36 const reason = checkVersionEdit(e.file_path, e.old_string, e.new_string)
37 return reason ? refuse($, reason) : next(e)
38 })
39
40 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
41 if (!e.file_path.includes(SCOPE) || !versionFile(e.file_path)) return next(e)
42 const before = (await $.fs.exists(e.file_path)) ? await $.fs.read(e.file_path) : ''
43 const reason = checkVersionEdit(e.file_path, before, e.content)
44 return reason ? refuse($, reason) : next(e)
45 })
46}
47
48function refuse($: EngineInterface, reason: string) {
49 $.ui.toast('feb-guard blocked a command')
50 return { deny: `feb-guard: ${reason}` }
51}
52
53// "Deploy from a state that is pushed, so live always matches a commit."
54async function unpushedState($: EngineInterface, cwd: string): Promise<string | undefined> {
55 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
56 if (top.exitCode !== 0) return undefined
57 const root = top.stdout.trim()
58
59 const dirty = await $.process.run(['git', 'status', '--porcelain', '--', APP_DIR], { cwd: root })
60 if (dirty.exitCode === 0 && dirty.stdout.trim()) {
61 return `${APP_DIR} has uncommitted changes, so what goes live would match no commit. Commit and push first, then deploy.\n${dirty.stdout.trim()}`
62 }
63
64 const ahead = await $.process.run(['git', 'rev-list', '--count', '@{u}..HEAD'], { cwd: root })
65 if (ahead.exitCode === 0 && Number(ahead.stdout.trim()) > 0) {
66 return `${ahead.stdout.trim()} commit(s) are not pushed yet. Push first, deploy second, so live always matches a commit on GitHub.`
67 }
68 return undefined
69}
70hooks/rules.ts 196 lines1// Pure checks, no `$`, so the tests can drive them directly.
2// Each returns the reason to refuse, or undefined to let the command through.
3
4export type Overrides = { nonHostingDeploy: boolean }
5
6// Split a shell command into simple commands and those into words. Rough on
7// purpose: it handles quotes and the usual separators, not every corner of sh.
8export function segments(command: string): string[][] {
9 const out: string[][] = []
10 let words: string[] = []
11 let word = ''
12 let hasWord = false
13 let quote: '"' | "'" | null = null
14
15 const endWord = () => {
16 if (hasWord) words.push(word)
17 word = ''
18 hasWord = false
19 }
20 const endSegment = () => {
21 endWord()
22 if (words.length) out.push(words)
23 words = []
24 }
25
26 for (let i = 0; i < command.length; i++) {
27 const c = command[i]!
28 if (quote) {
29 if (c === quote) quote = null
30 else if (c === '\\' && quote === '"' && i + 1 < command.length) word += command[++i]!
31 else word += c
32 continue
33 }
34 if (c === '"' || c === "'") { quote = c; hasWord = true; continue }
35 if (c === '\\' && i + 1 < command.length) { word += command[++i]!; hasWord = true; continue }
36 if (c === ';' || c === '\n' || c === '|' || c === '&' || c === '(' || c === ')') { endSegment(); continue }
37 if (c === ' ' || c === '\t') { endWord(); continue }
38 word += c
39 hasWord = true
40 }
41 endSegment()
42 return out
43}
44
45// Drop leading env assignments and wrappers so `FOO=1 npx firebase deploy`
46// reads as `firebase deploy`.
47function strip(words: string[]): string[] {
48 let i = 0
49 while (i < words.length && (/^\w+=/.test(words[i]!) || ['npx', 'command', 'exec', 'time', 'sudo', 'env'].includes(words[i]!))) i++
50 return words.slice(i)
51}
52
53// `git -C dir push` and `git --no-pager tag` still count as push and tag.
54function gitSub(words: string[]): { sub: string; args: string[] } | undefined {
55 if (words[0] !== 'git') return undefined
56 let i = 1
57 while (i < words.length && words[i]!.startsWith('-')) {
58 if (words[i] === '-C' || words[i] === '-c') i++
59 i++
60 }
61 return i < words.length ? { sub: words[i]!, args: words.slice(i + 1) } : undefined
62}
63
64const HOW_RELEASE = 'Releases go through `node tools/release.mjs <version>` from the repo root (CLAUDE.md, "One version, one release").'
65
66export function checkFirebase(args: string[], o: Overrides): string | undefined {
67 if (args[0] !== 'deploy') return undefined
68 let only: string | undefined
69 for (let i = 1; i < args.length; i++) {
70 const a = args[i]!
71 if (a === '--only') only = args[i + 1]
72 else if (a.startsWith('--only=')) only = a.slice(7)
73 }
74 if (only === undefined) {
75 return 'A bare `firebase deploy` also ships firestore.rules, storage.rules and functions. The standing authorization covers `firebase deploy --only hosting` and nothing else.'
76 }
77 const targets = only.split(',').map(t => t.trim()).filter(Boolean)
78 const others = targets.filter(t => t !== 'hosting' && !t.startsWith('hosting:'))
79 if (others.length && !o.nonHostingDeploy) {
80 return `\`--only ${only}\` deploys ${others.join(', ')}. Rules can lock the team out of their own data and functions are deployed separately on purpose, so this needs Simon: ask him, and if he agrees he types "allow rules deploy" (or "allow functions deploy") in his next message.`
81 }
82 return undefined
83}
84
85export function checkGit(sub: string, args: string[]): string | undefined {
86 if (sub === 'tag' && args.some(a => a.startsWith('addin-v'))) {
87 return `There is no separate add-in version and no \`addin-v*\` tag. ${HOW_RELEASE}`
88 }
89
90 if (sub === 'remote' && (args[0] === 'add' || args[0] === 'set-url') && args.some(isSsh)) {
91 return 'Keep the remote on HTTPS. The SSH key on this machine authenticates as `starbuckgold`, but the repo belongs to `Jinxiewinx` (the gh account).'
92 }
93
94 if (sub !== 'push') return undefined
95 if (args.some(isSsh)) {
96 return 'Push over HTTPS. The SSH key on this machine authenticates as `starbuckgold`, not `Jinxiewinx`.'
97 }
98 if (args.some(a => a.includes('addin-v'))) {
99 return `There is no separate add-in version and no \`addin-v*\` tag. ${HOW_RELEASE}`
100 }
101
102 const flagForce = args.some(a => a === '-f' || a === '--force' || a.startsWith('--force-with-lease') || a.startsWith('--force-if-includes') || (/^-[a-zA-Z]+$/.test(a) && a.includes('f')))
103 const positional = args.filter(a => !a.startsWith('-'))
104 const refspecs = positional.slice(1)
105 const plusForce = refspecs.some(r => r.startsWith('+'))
106 if (!flagForce && !plusForce) return undefined
107
108 const dst = (r: string) => {
109 const s = r.replace(/^\+/, '')
110 const d = s.includes(':') ? s.split(':')[1] : s
111 return (d ?? '').replace(/^refs\/heads\//, '')
112 }
113 const hitsMain = refspecs.length === 0 || refspecs.some(r => ['main', 'master', 'HEAD'].includes(dst(r)))
114 if (hitsMain || args.includes('--all') || args.includes('--mirror')) {
115 return 'No force-push over real history on main (SN6 Resources/CLAUDE.md). If history really needs rewriting, that is a call for Simon to make and run himself.'
116 }
117 return undefined
118}
119
120function isSsh(a: string): boolean {
121 return /^git@github\.com:/.test(a) || /^ssh:\/\//.test(a)
122}
123
124const VERSION_TOKENS = /ADDIN_VERSION|APP_VERSION|FEBPlanStock\.manifest/
125
126function checkInPlaceEdit(words: string[]): string | undefined {
127 const tool = words[0]
128 const inPlace = (tool === 'sed' && words.some(w => /^-[a-zA-Z]*i/.test(w) || w === '--in-place' || w.startsWith('--in-place=')))
129 || (tool === 'perl' && words.some(w => /^-[a-zA-Z]*i/.test(w)))
130 if (inPlace && words.some(w => VERSION_TOKENS.test(w))) {
131 return `Version strings are written only by the release script, which keeps APP_VERSION, the add-in manifest and ADDIN_VERSION in step. ${HOW_RELEASE}`
132 }
133 return undefined
134}
135
136export function checkBash(command: string, o: Overrides): string | undefined {
137 for (const raw of segments(command)) {
138 const words = strip(raw)
139 if (!words.length) continue
140 let reason: string | undefined
141 if (words[0] === 'firebase') reason = checkFirebase(words.slice(1), o)
142 else if (words[0] === 'gh' && words[1] === 'release' && ['create', 'upload', 'delete', 'edit'].includes(words[2] ?? '')) {
143 reason = `\`gh release ${words[2]}\` by hand is off: the release script publishes the Release with the add-in zip attached. ${HOW_RELEASE}`
144 } else {
145 const git = gitSub(words)
146 if (git) reason = checkGit(git.sub, git.args)
147 else reason = checkInPlaceEdit(words)
148 }
149 if (reason) return reason
150 }
151 return undefined
152}
153
154// True when the command is a hosting deploy that should then be checked for a
155// pushed tree. Separate from checkBash because that check needs git.
156export function isHostingDeploy(command: string): boolean {
157 return segments(command).some(raw => {
158 const w = strip(raw)
159 if (w[0] !== 'firebase' || w[1] !== 'deploy') return false
160 const i = w.findIndex(a => a === '--only' || a.startsWith('--only='))
161 if (i < 0) return false
162 const flag = w[i]!
163 const only = flag.startsWith('--only=') ? flag.slice(7) : w[i + 1] ?? ''
164 return only.split(',').some(t => t === 'hosting' || t.startsWith('hosting:'))
165 })
166}
167
168// Edit/Write on the three files that carry the version. Returns the reason
169// when the change touches a version line.
170export const VERSION_FILES: { suffix: string; line: RegExp }[] = [
171 { suffix: '06 Composites App/app/core.js', line: /\bAPP_VERSION\s*=\s*["'][^"']*["']/ },
172 { suffix: 'FEBPlanStock/FEBPlanStock.manifest', line: /"version"\s*:\s*"[^"]*"/ },
173 { suffix: 'FEBPlanStock/FEBPlanStock.py', line: /\bADDIN_VERSION\s*=\s*["'][^"']*["']/ },
174]
175
176export function versionFile(path: string) {
177 return VERSION_FILES.find(f => path.endsWith(f.suffix))
178}
179
180export function versionOf(text: string, line: RegExp): string | undefined {
181 return text.match(line)?.[0]
182}
183
184export function checkVersionEdit(path: string, before: string, after: string): string | undefined {
185 const f = versionFile(path)
186 if (!f) return undefined
187 if (versionOf(before, f.line) !== versionOf(after, f.line)) {
188 return `This changes the version line in ${f.suffix}. ${HOW_RELEASE} tools/test_addin_package.mjs fails when the three version strings drift.`
189 }
190 return undefined
191}
192
193export function wantsNonHosting(prompt: string): boolean {
194 return /\ballow (rules|firestore|storage|functions) deploy\b/i.test(prompt)
195}
196