SLOPSHOPPER

feb-guard

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…

newguardtoastpromptprocess
★ 1v0.1.0no licenseupdated 2026-10-07Jinxiewinx/feb-engineering-apps/.claude/skills/feb-guard
A shopper browsing a rack in a slop shop
README

SN6 Resources — FEB Composites

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.

What's in here

FolderContentsStart 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 fixesthe .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 themCS-INDEX
03 Datasheets/25 manufacturer TDS/SDS PDFs for the products we actually useINDEX.md
04 Printables/Shop reference sheets meant to be printed: resin ratios, flowcharts, checklistsREADME.md
05 Design System/The app's visual language as a reusable system: tokens, component CSS, a living style guidestyleguide.html
06 Composites App/The composites work-order app, live at feb-composites.web.appapp/README.md
07 CFD PDF Viewer/The desktop original of the CFD viewer, kept buildable; the hosted version lives in 08README.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 shellREADME.md
09 Website/The public team website, built on the design system. Not deployed; its README has the state of itREADME.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 installer10 Fusion Add-in/README.md
tools/Everything that builds and checks the rest: the docx builder, the generators, the servers, and 24 test suitesREADME.md

Getting started

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.

The app

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.

Dashboard: the pit board — four lanes, none of which can render empty

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

  • Dashboard: the pit board. Four lanes, each a question: Stopped, Waiting on you (walks the same gate ladder the buy-off button does, so it never promises a signature the button refuses), Due this week, and On the clock. An empty lane says so in a sentence; below sit the program numbers and the shop footer.
  • Season: the blueprint that replaced the Master Tracker spreadsheet. One columnated line per part the team means to make, mostly blank until the team knows more, and a line is a real part record from the moment it exists. The Google Sheet is downstream, republished every 15 minutes.
  • Work Orders: the manufacturing traveler. Steps with named buy-offs, blocker steps, cure holds enforced from the resin datasheets, per-step photos, and issues that block Complete until they carry a resolution, a root cause and an account of what was done about it. All three stay readable on the run afterwards. Prints to a hand-fillable sheet that is always exactly two pages.
  • Parts: every part down the left, the selected one beside it, each stage a row of steps you click. A part shows a photo of itself at the top, and can be made on several molds, so a split mold has both halves on the record with a progress bar each.
  • Molds: the mold pipeline. A mold carries its stage, home location, sealing record and mold file; the planner slices an STL into board layers, splits at the ShopSabre depth limit, nests blanks onto the cheapest boards on the rack, and prints dimensioned drawing sets and cut sheets. "Mark these boards cut" updates the rack, offcuts included, with one Undo.
  • R&D: the whole trial programme on one screen. A strip of cards across the top carries every study and every R&D part, with that part's runs as chips inside its card; pressing one opens it full width underneath. Coupon studies are still a grid you type into with no work order and no cure hold, and Compare gives means and ranges once a study has a swept setting and results. A study also works as a folder: file parts and runs under it and they group together. R&D records live here and only here, so Parts and Work Orders stay the season lists.
  • Inventory: the storage map, one card per shelf with contents and warnings; flat lists for items, materials and the tooling-board rack; a spreadsheet-shaped Receiving page that runs the CS-011 chemical checks; and per-material run-out that turns into a Restock purchase.
  • Schedule: the season as a station-by-week grid, or the week by day, subteam and person.
  • Budget: purchases on two tracks, the goods (Submitted, Purchased, Arrived) and the money (Submitted, Approved, Reimbursed). A receipt photo or PDF invoice reads itself into line items (Claude Haiku 5.5, through the app's one Cloud Function), and a Charged to field covers spend that belongs to another team's budget.
  • Documents: the 25 datasheets, member uploads, and pinned Google Docs in one filterable shelf.
  • Reports: CSV exports, the printable Monday status board, and the bulk label builder.
  • People: the roster, roles and trainings. Trainings gate work: a tagged step refuses an untrained signer unless a lead overrides with a logged reason.

Inventory: the storage map, one card per shelf with contents and warnings

Parts: the index of every part beside the selected one, each stage a row of steps

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.

Labels and scanning

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.

Labels: a printed Avery sheet with IDs, key facts and QR codes

Scanning: the public nameplate a phone camera opens, no sign-in

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.

The CFD PDF viewer

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.

The Panels view: the same named plot pulled from every open report

The rest, briefly

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.

Tests

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/.

Claude Code guardrails

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.

Source 2 files
hooks/register.ts 70 lines
1import 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}
70
hooks/rules.ts 196 lines
1// 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