voidforge-build 23.11.3 → 23.12.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.
- package/dist/.claude/agents/batman-qa.md +1 -0
- package/dist/.claude/agents/galadriel-frontend.md +2 -0
- package/dist/.claude/agents/kusanagi-devops.md +4 -0
- package/dist/.claude/agents/lucius-config.md +6 -0
- package/dist/.claude/agents/silver-surfer-herald.md +11 -4
- package/dist/.claude/commands/architect.md +9 -0
- package/dist/.claude/commands/assemble.md +4 -1
- package/dist/.claude/commands/assess.md +13 -1
- package/dist/.claude/commands/audit-docs.md +106 -0
- package/dist/.claude/commands/deploy.md +28 -0
- package/dist/.claude/commands/engage.md +2 -0
- package/dist/.claude/commands/gauntlet.md +23 -4
- package/dist/.claude/commands/imagine.md +15 -0
- package/dist/.claude/commands/ux.md +32 -0
- package/dist/.claude/commands/void.md +1 -0
- package/dist/CHANGELOG.md +68 -0
- package/dist/CLAUDE.md +9 -0
- package/dist/VERSION.md +3 -1
- package/dist/docs/methods/AI_INTELLIGENCE.md +33 -0
- package/dist/docs/methods/ASSEMBLER.md +31 -2
- package/dist/docs/methods/BUILD_PROTOCOL.md +1 -0
- package/dist/docs/methods/CAMPAIGN.md +31 -3
- package/dist/docs/methods/DEVOPS_ENGINEER.md +158 -0
- package/dist/docs/methods/DOC_AUDIT.md +92 -0
- package/dist/docs/methods/FORGE_KEEPER.md +16 -5
- package/dist/docs/methods/GAUNTLET.md +33 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +54 -0
- package/dist/docs/methods/QA_ENGINEER.md +20 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +27 -0
- package/dist/docs/methods/SUB_AGENTS.md +31 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +13 -0
- package/dist/docs/methods/TESTING.md +19 -0
- package/dist/docs/patterns/README.md +3 -0
- package/dist/docs/patterns/ai-eval.ts +63 -0
- package/dist/docs/patterns/autonomous-ops-triage-policy.md +102 -0
- package/dist/docs/patterns/daemon-process.ts +90 -0
- package/dist/docs/patterns/deploy-preflight.ts +85 -2
- package/dist/docs/patterns/design-tokens.ts +338 -0
- package/dist/docs/patterns/error-message-categorization.tsx +376 -0
- package/dist/wizard/lib/patterns/daemon-process.d.ts +2 -1
- package/dist/wizard/lib/patterns/daemon-process.js +89 -1
- package/package.json +2 -2
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Autonomous Operations Triage Policy
|
|
2
|
+
|
|
3
|
+
**Scope:** Ops-flavored projects (infrastructure repos, monitoring daemons, homelab automation, scheduled-task systems) where the assistant is invoked autonomously and must decide whether to act, propose, or escalate without the operator present.
|
|
4
|
+
|
|
5
|
+
**Status:** Pattern v1 (v23.11.4). Promoted by Wong from field reports #337, #336, #334.
|
|
6
|
+
|
|
7
|
+
**Why this exists:** Two operators independently reinvented the same 4-bucket model across three projects (threadplex-ops, 1999collection M30-M32, 1999collection M20-M28). The pattern is reusable across all infrastructure repos. Codifying it stops the reinvention.
|
|
8
|
+
|
|
9
|
+
## The 4-Bucket Model
|
|
10
|
+
|
|
11
|
+
Classify every proposed autonomous action into exactly one bucket:
|
|
12
|
+
|
|
13
|
+
| Bucket | Action | When | Logging | Operator Notification |
|
|
14
|
+
|--------|--------|------|---------|----------------------|
|
|
15
|
+
| **A — Self-resolving** | Auto-execute | Action is fully reversible, low-blast-radius, has a clear procedure, and was authorized in a durable instruction (CLAUDE.md, agent definition, prior issue) | Append to ops log | None unless asked |
|
|
16
|
+
| **B — Runbook-safe** | Follow runbook procedure | Action is documented in a runbook, has been executed successfully before, and operator pre-approved the runbook | Append to ops log with runbook ID | Summary at next session start |
|
|
17
|
+
| **C — Operator-approval required** | Propose via Telegram button / GitHub issue / explicit message; WAIT | Action has medium blast radius, irreversible side effects, OR runbook ambiguity | Log proposal + decision | Active notification (Telegram, Slack, email per project) |
|
|
18
|
+
| **D — Hard-never** | Log + escalate; NEVER attempt | Action is on the forbidden list (e.g., production rollback without ticket, secret rotation without quorum, destructive migration without approval) | Log attempt + escalation | Active high-priority alert |
|
|
19
|
+
|
|
20
|
+
## Pairing with SessionStart Hook
|
|
21
|
+
|
|
22
|
+
Ops projects should pair this policy with a `.claude/settings.json` SessionStart hook that injects current state and a reminder of the policy:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"hooks": {
|
|
27
|
+
"SessionStart": [
|
|
28
|
+
{
|
|
29
|
+
"command": "bash .claude/hooks/session-start.sh",
|
|
30
|
+
"description": "Inject current ops state + triage policy reminder"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The hook script outputs the current pending-action set (read from `logs/pending-actions.json` or similar) plus a one-line reminder pointing at this policy file.
|
|
38
|
+
|
|
39
|
+
### Visibility rule
|
|
40
|
+
|
|
41
|
+
**SessionStart hook output is context-only — the assistant must `echo` the relevant portions back to the operator at session start.** Otherwise the operator has no visual confirmation that the hook fired and the policy is live. Both #337 and #336 documented operators discovering that hooks were silently running with no visibility — both reinvented an explicit echo step. Make it part of the policy from day one.
|
|
42
|
+
|
|
43
|
+
## Decision Tree (Each Proposed Action)
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Is the action on the forbidden list (D)?
|
|
47
|
+
Yes → Log attempt, raise high-priority alert, STOP.
|
|
48
|
+
No → Continue.
|
|
49
|
+
|
|
50
|
+
Does the action have an approved runbook (B)?
|
|
51
|
+
Yes → Execute runbook, log with runbook ID. STOP.
|
|
52
|
+
No → Continue.
|
|
53
|
+
|
|
54
|
+
Is the action fully reversible AND low blast radius AND pre-authorized in durable instructions (A)?
|
|
55
|
+
Yes → Execute, log. STOP.
|
|
56
|
+
No → Bucket C: propose to operator, wait, do not execute until approved.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Logging Format
|
|
60
|
+
|
|
61
|
+
Each ops log entry should record:
|
|
62
|
+
|
|
63
|
+
```jsonl
|
|
64
|
+
{
|
|
65
|
+
"timestamp": "2026-05-12T19:42:00Z",
|
|
66
|
+
"bucket": "A|B|C|D",
|
|
67
|
+
"action": "short description",
|
|
68
|
+
"decision": "executed|proposed|escalated|skipped",
|
|
69
|
+
"rationale": "why this bucket",
|
|
70
|
+
"runbook_id": "RB-007 if applicable",
|
|
71
|
+
"operator_notified": false,
|
|
72
|
+
"outcome": "success|failed|pending"
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
JSON Lines (one object per line) keeps the log greppable and append-only.
|
|
77
|
+
|
|
78
|
+
## Adoption Checklist
|
|
79
|
+
|
|
80
|
+
For a new ops-flavored project:
|
|
81
|
+
|
|
82
|
+
- [ ] Pick the buckets that apply (most projects use all four)
|
|
83
|
+
- [ ] Write the forbidden list (Bucket D) FIRST — concrete and exhaustive
|
|
84
|
+
- [ ] Document each runbook (Bucket B) with a fixed ID, expected outcome, and rollback procedure
|
|
85
|
+
- [ ] Set up the SessionStart hook + echo step
|
|
86
|
+
- [ ] Configure the operator-notification channel (Telegram bot, GitHub issue, etc.)
|
|
87
|
+
- [ ] Establish the ops log path and rotation policy
|
|
88
|
+
- [ ] Add a one-line reminder of this policy to the project's `CLAUDE.md` Personality section
|
|
89
|
+
|
|
90
|
+
## Non-Goals
|
|
91
|
+
|
|
92
|
+
This pattern is NOT:
|
|
93
|
+
|
|
94
|
+
- A replacement for `/campaign` or `/build` — those are user-driven workflows with human pacing
|
|
95
|
+
- A general agent-permissioning model — settings.json `allow`/`deny` lists handle tool-level permissions
|
|
96
|
+
- A substitute for tested code — Bucket A actions still need their own correctness verification
|
|
97
|
+
|
|
98
|
+
## See Also
|
|
99
|
+
|
|
100
|
+
- `docs/patterns/daemon-process.ts` — daemon lifecycle, PID management, signal handling
|
|
101
|
+
- `docs/methods/HEARTBEAT.md` — long-running ops job scheduling
|
|
102
|
+
- `docs/methods/FIELD_MEDIC.md` — post-mortem analysis for ops incidents
|
|
@@ -325,6 +325,95 @@ function createLogger(logPath: string): { log: (msg: string) => void; close: ()
|
|
|
325
325
|
};
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
// ── .env Parsing (literal, $-safe) ────────────────────
|
|
329
|
+
// field report #344 F1: never source secrets via `export $(cat .env)` /
|
|
330
|
+
// `eval "$(cat .env)"`. The shell performs variable expansion and word
|
|
331
|
+
// splitting on the RHS, so a `$`-bearing secret — bcrypt hashes
|
|
332
|
+
// ($2b$...), JWTs, Postgres URLs with `$` in the password, anything with
|
|
333
|
+
// `$VAR`/`${...}`/backticks — gets mangled or silently truncated. Parse
|
|
334
|
+
// literally instead: read each line, split on the FIRST `=` only, and keep
|
|
335
|
+
// the value byte-for-byte (no expansion, no eval). For shells, the
|
|
336
|
+
// equivalent is `while IFS='=' read -r k v; do export "$k=$v"; done < .env`
|
|
337
|
+
// — note IFS='=' and `read -r` (raw, no backslash processing), which never
|
|
338
|
+
// re-expands the value.
|
|
339
|
+
//
|
|
340
|
+
// Prefer a runtime-native loader where available — it sidesteps the shell
|
|
341
|
+
// entirely:
|
|
342
|
+
// - Node 20.6+: `node --env-file=.env daemon.js` (literal parse, no shell).
|
|
343
|
+
// - systemd: `EnvironmentFile=/etc/voidforge/heartbeat.env` (also literal;
|
|
344
|
+
// unit-file `Environment=` lines do NOT undergo shell expansion).
|
|
345
|
+
// Use this helper only when you must parse `.env` in-process.
|
|
346
|
+
|
|
347
|
+
function parseDotenv(contents: string): Record<string, string> {
|
|
348
|
+
const out: Record<string, string> = {};
|
|
349
|
+
for (const rawLine of contents.split('\n')) {
|
|
350
|
+
const line = rawLine.replace(/\r$/, '');
|
|
351
|
+
// Skip blanks and comments. A leading `export ` prefix is tolerated.
|
|
352
|
+
const trimmed = line.trimStart();
|
|
353
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
354
|
+
const body = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed;
|
|
355
|
+
|
|
356
|
+
// Split on the FIRST `=` only — values may legitimately contain `=`.
|
|
357
|
+
const eq = body.indexOf('=');
|
|
358
|
+
if (eq < 0) continue; // not a KEY=VALUE line — ignore, don't guess
|
|
359
|
+
const key = body.slice(0, eq).trim();
|
|
360
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; // invalid env name
|
|
361
|
+
|
|
362
|
+
let value = body.slice(eq + 1);
|
|
363
|
+
// Strip a single layer of matching surrounding quotes. Inside quotes the
|
|
364
|
+
// value is taken LITERALLY — no `$` expansion, no eval — which is the
|
|
365
|
+
// whole point: `PASS='p@$$w0rd'` keeps its `$$` intact.
|
|
366
|
+
if (value.length >= 2 &&
|
|
367
|
+
((value[0] === '"' && value[value.length - 1] === '"') ||
|
|
368
|
+
(value[0] === "'" && value[value.length - 1] === "'"))) {
|
|
369
|
+
value = value.slice(1, -1);
|
|
370
|
+
} else {
|
|
371
|
+
// Unquoted: trim trailing inline whitespace only (POSIX-ish), never
|
|
372
|
+
// touch interior `$` characters.
|
|
373
|
+
value = value.trimEnd();
|
|
374
|
+
}
|
|
375
|
+
out[key] = value;
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── systemd hardening stanza (Node daemons) ───────────
|
|
381
|
+
// field report #344 F3: when running this daemon under systemd, harden the
|
|
382
|
+
// unit — but DO NOT set `MemoryDenyWriteExecute=true` for a Node/V8 process.
|
|
383
|
+
// V8's JIT allocates pages that are written and then executed (it manages its
|
|
384
|
+
// own W^X internally); MDWE forbids any write+exec mapping, so the daemon
|
|
385
|
+
// takes a SIGTRAP and dies at boot, usually before it logs a single line. The
|
|
386
|
+
// safe, high-value sandbox flags below give most of MDWE's benefit without the
|
|
387
|
+
// JIT collision:
|
|
388
|
+
//
|
|
389
|
+
// [Unit]
|
|
390
|
+
// Description=VoidForge Heartbeat daemon
|
|
391
|
+
// After=network-online.target
|
|
392
|
+
// Wants=network-online.target
|
|
393
|
+
//
|
|
394
|
+
// [Service]
|
|
395
|
+
// Type=simple
|
|
396
|
+
// ExecStart=/usr/bin/node /opt/voidforge/daemon.js
|
|
397
|
+
// EnvironmentFile=/etc/voidforge/heartbeat.env # literal parse — see #344 F1
|
|
398
|
+
// Restart=on-failure
|
|
399
|
+
// RestartSec=5
|
|
400
|
+
//
|
|
401
|
+
// # Hardening — keep these:
|
|
402
|
+
// NoNewPrivileges=true # no setuid/setgid privilege escalation
|
|
403
|
+
// ProtectSystem=full # /usr, /boot, /etc mounted read-only
|
|
404
|
+
// ProtectHome=true # /home, /root, /run/user hidden
|
|
405
|
+
// PrivateTmp=true # private /tmp + /var/tmp namespace
|
|
406
|
+
// # MemoryDenyWriteExecute=true # <-- OMITTED ON PURPOSE: breaks V8 JIT
|
|
407
|
+
// # (SIGTRAP at boot). Re-enable ONLY for
|
|
408
|
+
// # Go/Rust/static daemons with no JIT.
|
|
409
|
+
//
|
|
410
|
+
// [Install]
|
|
411
|
+
// WantedBy=multi-user.target
|
|
412
|
+
//
|
|
413
|
+
// Go, Rust, and other AOT-compiled daemons emit no executable pages at
|
|
414
|
+
// runtime, so for THEM you can and should keep `MemoryDenyWriteExecute=true`.
|
|
415
|
+
// The omission above is V8-specific, not a general weakening.
|
|
416
|
+
|
|
328
417
|
export {
|
|
329
418
|
writePidFile, checkStalePid, removePidFile,
|
|
330
419
|
generateSessionToken, validateToken,
|
|
@@ -333,6 +422,7 @@ export {
|
|
|
333
422
|
setupSignalHandlers,
|
|
334
423
|
JobScheduler,
|
|
335
424
|
createLogger,
|
|
425
|
+
parseDotenv,
|
|
336
426
|
PID_FILE, SOCKET_PATH, TOKEN_FILE, STATE_FILE, LOG_FILE,
|
|
337
427
|
};
|
|
338
428
|
export type { DaemonState, HeartbeatState, ScheduledJob };
|
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
* Reference implementation for .claude/commands/deploy.md Step 2.5.
|
|
5
5
|
* Scans the deploy artifact directory BEFORE upload. Exits non-zero on any hit.
|
|
6
6
|
*
|
|
7
|
-
* Evidence: field reports #305 (32-day credential leak), #303 (methodology exposure)
|
|
7
|
+
* Evidence: field reports #305 (32-day credential leak), #303 (methodology exposure),
|
|
8
|
+
* #343 F7 (stop-build-start loop mislabeled "blue-green" → 502 window every deploy).
|
|
8
9
|
*
|
|
9
10
|
* Key principles:
|
|
10
11
|
* - Scan the deploy payload directory, NOT the repo root.
|
|
11
12
|
* - Never auto-filter — a hit means the operator must investigate.
|
|
12
13
|
* - Never print secret content; only paths + pattern IDs.
|
|
13
14
|
* - Allowlist escape hatch via DEPLOY_PREFLIGHT_ALLOW (comma-separated globs).
|
|
15
|
+
* - Deploy-strategy claims must be backed by a real mechanism: a comment that
|
|
16
|
+
* says "blue-green"/"zero-downtime" without an atomic swap (rename, container
|
|
17
|
+
* swap, or LB cutover) is a lie that ships a 502 window (#343 F7).
|
|
14
18
|
*
|
|
15
19
|
* Usage:
|
|
16
20
|
* npx tsx docs/patterns/deploy-preflight.ts ./dist
|
|
@@ -55,11 +59,82 @@ const TEXT_EXTENSIONS = new Set([
|
|
|
55
59
|
]);
|
|
56
60
|
|
|
57
61
|
interface Hit {
|
|
58
|
-
kind: 'name' | 'content';
|
|
62
|
+
kind: 'name' | 'content' | 'strategy';
|
|
59
63
|
path: string;
|
|
60
64
|
patternId: string;
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
// ---------- deploy-strategy nomenclature check (field report #343 F7) ----------
|
|
68
|
+
// A stop-build-start loop mislabeled "blue-green"/"zero-downtime" still drops the
|
|
69
|
+
// old process before the new one is live, producing a 502 window on every deploy.
|
|
70
|
+
// The comment lies; the mechanism doesn't. This flags scripts whose comments CLAIM
|
|
71
|
+
// blue-green / zero-downtime but where no atomic-swap mechanism is detectable —
|
|
72
|
+
// temp-build-then-rename, container/image swap, or load-balancer cutover.
|
|
73
|
+
|
|
74
|
+
// File shapes that can carry a deploy strategy worth checking.
|
|
75
|
+
const DEPLOY_SCRIPT_EXTENSIONS = new Set([
|
|
76
|
+
'.sh', '.bash', '.zsh', '.yml', '.yaml', '.ps1',
|
|
77
|
+
]);
|
|
78
|
+
const DEPLOY_SCRIPT_BASENAMES = new Set([
|
|
79
|
+
'Dockerfile', 'Procfile', 'Makefile',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// Comments that CLAIM an atomic deploy strategy.
|
|
83
|
+
const STRATEGY_CLAIM_RE = /\b(blue[\s/_-]?green|zero[\s/_-]?downtime|hot[\s/_-]?swap|atomic\s+deploy(?:ment)?)\b/i;
|
|
84
|
+
|
|
85
|
+
// Any one of these signals a real atomic-swap mechanism is present.
|
|
86
|
+
const ATOMIC_SWAP_SIGNALS: { id: string; re: RegExp }[] = [
|
|
87
|
+
// temp build dir then rename/symlink-swap into place (release-then-link pattern)
|
|
88
|
+
{ id: 'rename-swap', re: /\b(?:mv|rename|ln\s+-s(?:fn|nf|f)?)\b[^\n]*\b(?:current|live|release|active|prod(?:uction)?)\b/i },
|
|
89
|
+
{ id: 'symlink-current', re: /\bln\s+-s(?:fn|nf|f)?\b[^\n]*\bcurrent\b/i },
|
|
90
|
+
// container / image swap: new container up, traffic moved, old removed
|
|
91
|
+
{ id: 'container-swap', re: /\bdocker\b[^\n]*\b(?:run|up|--scale|service\s+update)\b|\bdocker[\s-]compose\b[^\n]*\bup\b[^\n]*\b(?:--no-recreate|--scale)\b|\bcontainer[\s_-]?swap\b/i },
|
|
92
|
+
{ id: 'orchestrator-rollout', re: /\b(?:kubectl\s+rollout|helm\s+upgrade|nomad\s+job\s+run|ecs\b[^\n]*update-service)\b/i },
|
|
93
|
+
// load-balancer / proxy cutover: register new target, then drain/deregister old
|
|
94
|
+
{ id: 'lb-cutover', re: /\b(?:register-targets|deregister-targets|modify-listener|switchover|traffic[\s_-]?shift|weighted[\s_-]?routing|upstream)\b/i },
|
|
95
|
+
{ id: 'proxy-reload', re: /\b(?:nginx\s+-s\s+reload|caddy\s+reload|envoy\b[^\n]*config|haproxy\b[^\n]*reload)\b/i },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Sequences that betray a stop-build-start loop (kill old, then start new).
|
|
99
|
+
// Used only to strengthen the signal — a claim with NO atomic mechanism is
|
|
100
|
+
// already a hit; this just confirms the anti-pattern is actively present.
|
|
101
|
+
const STOP_START_RE = /\b(?:kill|stop|down|terminate|systemctl\s+stop|pm2\s+stop|docker\s+stop|docker\s+rm)\b[\s\S]{0,400}?\b(?:start|up|run|systemctl\s+start|pm2\s+start|npm\s+(?:run\s+)?start|node\b)/i;
|
|
102
|
+
|
|
103
|
+
function scanStrategy(fullPath: string, relPath: string): string | null {
|
|
104
|
+
const base = relPath.split(sep).pop() ?? '';
|
|
105
|
+
const ext = extname(fullPath).toLowerCase();
|
|
106
|
+
const looksLikeDeployScript =
|
|
107
|
+
DEPLOY_SCRIPT_EXTENSIONS.has(ext) ||
|
|
108
|
+
DEPLOY_SCRIPT_BASENAMES.has(base) ||
|
|
109
|
+
/deploy|release|rollout|cutover/i.test(base);
|
|
110
|
+
if (!looksLikeDeployScript) return null;
|
|
111
|
+
|
|
112
|
+
let stats;
|
|
113
|
+
try {
|
|
114
|
+
stats = statSync(fullPath);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (stats.size > 2_000_000) return null;
|
|
119
|
+
|
|
120
|
+
let buf: string;
|
|
121
|
+
try {
|
|
122
|
+
buf = readFileSync(fullPath, 'utf8');
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!STRATEGY_CLAIM_RE.test(buf)) return null; // no claim, nothing to verify
|
|
128
|
+
const hasAtomicSwap = ATOMIC_SWAP_SIGNALS.some((s) => s.re.test(buf));
|
|
129
|
+
if (hasAtomicSwap) return null; // claim is backed by a real mechanism
|
|
130
|
+
|
|
131
|
+
// Claim present, no atomic-swap mechanism. Distinguish the worst case:
|
|
132
|
+
// an actual stop-build-start loop wearing a blue-green label.
|
|
133
|
+
return STOP_START_RE.test(buf)
|
|
134
|
+
? 'strategy-mislabel-stop-start'
|
|
135
|
+
: 'strategy-claim-no-atomic-swap';
|
|
136
|
+
}
|
|
137
|
+
|
|
63
138
|
function globToRegex(glob: string): RegExp {
|
|
64
139
|
const escaped = glob
|
|
65
140
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
@@ -167,6 +242,14 @@ function main(): void {
|
|
|
167
242
|
const contentHit = scanContent(fullPath);
|
|
168
243
|
if (contentHit) {
|
|
169
244
|
hits.push({ kind: 'content', path: relPath, patternId: contentHit });
|
|
245
|
+
continue; // a secret hit is already terminal; don't double-report this file
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Deploy-strategy nomenclature check (field report #343 F7): a script whose
|
|
249
|
+
// comments claim blue-green / zero-downtime but ships no atomic-swap mechanism.
|
|
250
|
+
const strategyHit = scanStrategy(fullPath, relPath);
|
|
251
|
+
if (strategyHit) {
|
|
252
|
+
hits.push({ kind: 'strategy', path: relPath, patternId: strategyHit });
|
|
170
253
|
}
|
|
171
254
|
}
|
|
172
255
|
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Semantic Design Tokens (primitive -> semantic -> CSS custom properties)
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - NO raw color or type values in components. Components reference SEMANTIC
|
|
6
|
+
* tokens only (e.g., `surface.raised`, `text.muted`, `font.size.body`) —
|
|
7
|
+
* never primitives (e.g., `#4f46e5`, `gray-200`, `16px`).
|
|
8
|
+
* - Two layers: PRIMITIVES (the raw palette/scale — every hex, every step of
|
|
9
|
+
* the type scale) and SEMANTIC roles (what a thing MEANS — `accent`,
|
|
10
|
+
* `danger`, `border.subtle`). Semantic roles map TO primitives.
|
|
11
|
+
* - Tokens are emitted as CSS custom properties (`--vf-color-accent`) so a
|
|
12
|
+
* theme override is a `:root[data-theme=...]` block — not a recompile.
|
|
13
|
+
* - A palette or type pivot becomes a TOKEN edit (re-point semantic roles at
|
|
14
|
+
* different primitives), not a component-by-component rewrite.
|
|
15
|
+
*
|
|
16
|
+
* The trap (field report #351, #3): a rebrand asked for a new accent color and
|
|
17
|
+
* a tighter type scale. Components had hardcoded `#4f46e5` and `text-[15px]`
|
|
18
|
+
* inline in 60+ files. The "one color change" turned into a 60-file grep-and-
|
|
19
|
+
* replace that missed five spots (shipped a two-tone accent for a week) and
|
|
20
|
+
* couldn't support a dark theme at all because the values had no indirection.
|
|
21
|
+
* Scoping all color/type to semantic tokens makes the pivot a single edit.
|
|
22
|
+
*
|
|
23
|
+
* Agents: Galadriel (design system), Legolas (code), Samwise (a11y contrast)
|
|
24
|
+
*
|
|
25
|
+
* Framework adaptations:
|
|
26
|
+
* React/Next.js: This file — emit CSS vars into a <style> at the root, read
|
|
27
|
+
* them via Tailwind theme extension or plain `var(--vf-...)`.
|
|
28
|
+
* Tailwind: map semantic tokens into `theme.extend.colors` /
|
|
29
|
+
* `theme.extend.fontSize` as `var(--vf-...)` so `bg-accent` resolves to the
|
|
30
|
+
* token (see the Tailwind block at the bottom).
|
|
31
|
+
* Vue/Svelte: same CSS-var output; bind `data-theme` on a root element.
|
|
32
|
+
* Django/Rails templates: emit the `:root` block from `tokensToCss()` into a
|
|
33
|
+
* server-rendered <style> tag in the base layout; components use `var(...)`.
|
|
34
|
+
*
|
|
35
|
+
* Pairs with /docs/patterns/component.tsx (components consume tokens, never
|
|
36
|
+
* raw values) and /docs/patterns/combobox.tsx (a11y-critical surfaces).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ── Layer 1: Primitives (the raw palette + scales) ───────────────────────────
|
|
40
|
+
// These are the ONLY place raw values live. Name them by what they ARE
|
|
41
|
+
// (a swatch index, a scale step), never by what they're FOR. A primitive
|
|
42
|
+
// never appears directly in a component.
|
|
43
|
+
|
|
44
|
+
export const primitives = {
|
|
45
|
+
color: {
|
|
46
|
+
// Neutral ramp
|
|
47
|
+
white: '#ffffff',
|
|
48
|
+
black: '#0a0a0a',
|
|
49
|
+
gray50: '#f9fafb',
|
|
50
|
+
gray100: '#f3f4f6',
|
|
51
|
+
gray200: '#e5e7eb',
|
|
52
|
+
gray500: '#6b7280',
|
|
53
|
+
gray700: '#374151',
|
|
54
|
+
gray900: '#111827',
|
|
55
|
+
// Brand ramp
|
|
56
|
+
indigo500: '#6366f1',
|
|
57
|
+
indigo600: '#4f46e5',
|
|
58
|
+
indigo700: '#4338ca',
|
|
59
|
+
// Status ramps
|
|
60
|
+
red500: '#ef4444',
|
|
61
|
+
red600: '#dc2626',
|
|
62
|
+
green500: '#22c55e',
|
|
63
|
+
amber500: '#f59e0b',
|
|
64
|
+
},
|
|
65
|
+
// Type scale — a modular scale, not arbitrary pixels.
|
|
66
|
+
fontSize: {
|
|
67
|
+
xs: '0.75rem',
|
|
68
|
+
sm: '0.875rem',
|
|
69
|
+
base: '1rem',
|
|
70
|
+
lg: '1.125rem',
|
|
71
|
+
xl: '1.5rem',
|
|
72
|
+
'2xl': '2rem',
|
|
73
|
+
},
|
|
74
|
+
fontWeight: {
|
|
75
|
+
regular: '400',
|
|
76
|
+
medium: '500',
|
|
77
|
+
semibold: '600',
|
|
78
|
+
bold: '700',
|
|
79
|
+
},
|
|
80
|
+
lineHeight: {
|
|
81
|
+
tight: '1.2',
|
|
82
|
+
normal: '1.5',
|
|
83
|
+
relaxed: '1.7',
|
|
84
|
+
},
|
|
85
|
+
fontFamily: {
|
|
86
|
+
sans: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
87
|
+
mono: 'ui-monospace, "SF Mono", "Cascadia Code", monospace',
|
|
88
|
+
},
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
type PrimitiveColor = keyof typeof primitives.color;
|
|
92
|
+
type PrimitiveFontSize = keyof typeof primitives.fontSize;
|
|
93
|
+
type PrimitiveFontWeight = keyof typeof primitives.fontWeight;
|
|
94
|
+
type PrimitiveLineHeight = keyof typeof primitives.lineHeight;
|
|
95
|
+
type PrimitiveFontFamily = keyof typeof primitives.fontFamily;
|
|
96
|
+
|
|
97
|
+
// ── Layer 2: Semantic tokens (what a thing MEANS) ────────────────────────────
|
|
98
|
+
// A semantic token is a NAME FOR A ROLE that points at a primitive. Components
|
|
99
|
+
// reference these. Re-point them to pivot the whole product. The shape is the
|
|
100
|
+
// contract — a theme override must provide every role, so the type system
|
|
101
|
+
// guarantees no role is left un-themed.
|
|
102
|
+
|
|
103
|
+
export type SemanticTokens = {
|
|
104
|
+
color: {
|
|
105
|
+
'bg.canvas': PrimitiveColor; // page background
|
|
106
|
+
'bg.raised': PrimitiveColor; // cards, popovers
|
|
107
|
+
'text.default': PrimitiveColor;
|
|
108
|
+
'text.muted': PrimitiveColor;
|
|
109
|
+
'text.on-accent': PrimitiveColor; // text placed on top of `accent`
|
|
110
|
+
'border.subtle': PrimitiveColor;
|
|
111
|
+
accent: PrimitiveColor; // primary action / brand
|
|
112
|
+
'accent.hover': PrimitiveColor;
|
|
113
|
+
danger: PrimitiveColor;
|
|
114
|
+
success: PrimitiveColor;
|
|
115
|
+
warning: PrimitiveColor;
|
|
116
|
+
};
|
|
117
|
+
type: {
|
|
118
|
+
'size.caption': PrimitiveFontSize;
|
|
119
|
+
'size.body': PrimitiveFontSize;
|
|
120
|
+
'size.heading': PrimitiveFontSize;
|
|
121
|
+
'size.display': PrimitiveFontSize;
|
|
122
|
+
'weight.body': PrimitiveFontWeight;
|
|
123
|
+
'weight.emphasis': PrimitiveFontWeight;
|
|
124
|
+
'leading.body': PrimitiveLineHeight;
|
|
125
|
+
'leading.heading': PrimitiveLineHeight;
|
|
126
|
+
'family.ui': PrimitiveFontFamily;
|
|
127
|
+
'family.code': PrimitiveFontFamily;
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ── Worked example, step 1: define the default (light) theme ─────────────────
|
|
132
|
+
// Every semantic role points at a primitive. This is the whole brand surface —
|
|
133
|
+
// change these mappings and the product re-skins. Note: no hex here, only
|
|
134
|
+
// references into `primitives`.
|
|
135
|
+
|
|
136
|
+
export const lightTheme: SemanticTokens = {
|
|
137
|
+
color: {
|
|
138
|
+
'bg.canvas': 'white',
|
|
139
|
+
'bg.raised': 'gray50',
|
|
140
|
+
'text.default': 'gray900',
|
|
141
|
+
'text.muted': 'gray500',
|
|
142
|
+
'text.on-accent': 'white',
|
|
143
|
+
'border.subtle': 'gray200',
|
|
144
|
+
accent: 'indigo600',
|
|
145
|
+
'accent.hover': 'indigo700',
|
|
146
|
+
danger: 'red600',
|
|
147
|
+
success: 'green500',
|
|
148
|
+
warning: 'amber500',
|
|
149
|
+
},
|
|
150
|
+
type: {
|
|
151
|
+
'size.caption': 'sm',
|
|
152
|
+
'size.body': 'base',
|
|
153
|
+
'size.heading': 'xl',
|
|
154
|
+
'size.display': '2xl',
|
|
155
|
+
'weight.body': 'regular',
|
|
156
|
+
'weight.emphasis': 'semibold',
|
|
157
|
+
'leading.body': 'normal',
|
|
158
|
+
'leading.heading': 'tight',
|
|
159
|
+
'family.ui': 'sans',
|
|
160
|
+
'family.code': 'mono',
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// ── Worked example, step 2: a theme override (dark) ──────────────────────────
|
|
165
|
+
// Because color roles are indirection, a dark theme is just a different
|
|
166
|
+
// primitive mapping. The type scale is unchanged here — override only what
|
|
167
|
+
// differs. A full rebrand would supply a new `primitives.color` ramp AND a new
|
|
168
|
+
// mapping; both still live in tokens, never in components.
|
|
169
|
+
|
|
170
|
+
export const darkTheme: SemanticTokens = {
|
|
171
|
+
color: {
|
|
172
|
+
'bg.canvas': 'black',
|
|
173
|
+
'bg.raised': 'gray900',
|
|
174
|
+
'text.default': 'gray50',
|
|
175
|
+
'text.muted': 'gray500',
|
|
176
|
+
'text.on-accent': 'white',
|
|
177
|
+
'border.subtle': 'gray700',
|
|
178
|
+
accent: 'indigo500', // lighter accent reads better on dark canvas
|
|
179
|
+
'accent.hover': 'indigo600',
|
|
180
|
+
danger: 'red500',
|
|
181
|
+
success: 'green500',
|
|
182
|
+
warning: 'amber500',
|
|
183
|
+
},
|
|
184
|
+
type: lightTheme.type, // type scale is theme-invariant in this example
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// ── Emit: semantic tokens -> CSS custom properties ───────────────────────────
|
|
188
|
+
// `--vf-color-<role>` and `--vf-type-<role>`, with `.` and braces flattened to
|
|
189
|
+
// `-`. The variable VALUE is the resolved primitive, so consumers never touch
|
|
190
|
+
// primitives directly. Emit one block per theme keyed by `data-theme`.
|
|
191
|
+
|
|
192
|
+
const CSS_VAR_PREFIX = '--vf';
|
|
193
|
+
|
|
194
|
+
function cssVarName(group: 'color' | 'type', role: string): string {
|
|
195
|
+
// 'accent.hover' -> '--vf-color-accent-hover'
|
|
196
|
+
const safeRole = role.replace(/[.\s]+/g, '-');
|
|
197
|
+
return `${CSS_VAR_PREFIX}-${group}-${safeRole}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveColor(role: PrimitiveColor): string {
|
|
201
|
+
return primitives.color[role];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveType(
|
|
205
|
+
tokenKey: keyof SemanticTokens['type'],
|
|
206
|
+
ref: string,
|
|
207
|
+
): string {
|
|
208
|
+
if (tokenKey.startsWith('size.')) return primitives.fontSize[ref as PrimitiveFontSize];
|
|
209
|
+
if (tokenKey.startsWith('weight.')) return primitives.fontWeight[ref as PrimitiveFontWeight];
|
|
210
|
+
if (tokenKey.startsWith('leading.')) return primitives.lineHeight[ref as PrimitiveLineHeight];
|
|
211
|
+
if (tokenKey.startsWith('family.')) return primitives.fontFamily[ref as PrimitiveFontFamily];
|
|
212
|
+
throw new Error(`Unknown type token group: ${tokenKey}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Build the `--vf-*` declarations for one theme as `key: value` lines. */
|
|
216
|
+
export function themeToDeclarations(tokens: SemanticTokens): Record<string, string> {
|
|
217
|
+
const out: Record<string, string> = {};
|
|
218
|
+
for (const [role, ref] of Object.entries(tokens.color)) {
|
|
219
|
+
out[cssVarName('color', role)] = resolveColor(ref as PrimitiveColor);
|
|
220
|
+
}
|
|
221
|
+
for (const [role, ref] of Object.entries(tokens.type)) {
|
|
222
|
+
out[cssVarName('type', role)] = resolveType(
|
|
223
|
+
role as keyof SemanticTokens['type'],
|
|
224
|
+
ref as string,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Render every theme into a single CSS string: `:root` carries the default
|
|
232
|
+
* theme, `[data-theme="<name>"]` carries each override. Drop this into a
|
|
233
|
+
* <style> tag (React: dangerouslySetInnerHTML; templates: server-render).
|
|
234
|
+
*/
|
|
235
|
+
export function tokensToCss(themes: {
|
|
236
|
+
default: SemanticTokens;
|
|
237
|
+
overrides?: Record<string, SemanticTokens>;
|
|
238
|
+
}): string {
|
|
239
|
+
const block = (selector: string, decls: Record<string, string>): string => {
|
|
240
|
+
const body = Object.entries(decls)
|
|
241
|
+
.map(([k, v]) => ` ${k}: ${v};`)
|
|
242
|
+
.join('\n');
|
|
243
|
+
return `${selector} {\n${body}\n}`;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const parts = [block(':root', themeToDeclarations(themes.default))];
|
|
247
|
+
for (const [name, theme] of Object.entries(themes.overrides ?? {})) {
|
|
248
|
+
parts.push(block(`[data-theme="${name}"]`, themeToDeclarations(theme)));
|
|
249
|
+
}
|
|
250
|
+
return parts.join('\n\n');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Worked example, step 3: consume tokens in a component ─────────────────────
|
|
254
|
+
// The component references SEMANTIC tokens via `var(--vf-...)` — never a hex,
|
|
255
|
+
// never a pixel literal. Swapping `data-theme` on any ancestor re-themes it
|
|
256
|
+
// with zero component changes. This is the payoff: the pivot lives in tokens.
|
|
257
|
+
|
|
258
|
+
import type { CSSProperties, PropsWithChildren } from 'react';
|
|
259
|
+
|
|
260
|
+
const tokenColor = (role: keyof SemanticTokens['color']): string =>
|
|
261
|
+
`var(${cssVarName('color', role)})`;
|
|
262
|
+
const tokenType = (role: keyof SemanticTokens['type']): string =>
|
|
263
|
+
`var(${cssVarName('type', role)})`;
|
|
264
|
+
|
|
265
|
+
export function CalloutCard({ children }: PropsWithChildren): JSX.Element {
|
|
266
|
+
const style: CSSProperties = {
|
|
267
|
+
background: tokenColor('bg.raised'),
|
|
268
|
+
color: tokenColor('text.default'),
|
|
269
|
+
border: `1px solid ${tokenColor('border.subtle')}`,
|
|
270
|
+
borderRadius: 8,
|
|
271
|
+
padding: '1rem 1.25rem',
|
|
272
|
+
fontFamily: tokenType('family.ui'),
|
|
273
|
+
fontSize: tokenType('size.body'),
|
|
274
|
+
lineHeight: tokenType('leading.body'),
|
|
275
|
+
};
|
|
276
|
+
const ctaStyle: CSSProperties = {
|
|
277
|
+
background: tokenColor('accent'),
|
|
278
|
+
color: tokenColor('text.on-accent'),
|
|
279
|
+
fontWeight: tokenType('weight.emphasis'),
|
|
280
|
+
border: 'none',
|
|
281
|
+
borderRadius: 6,
|
|
282
|
+
padding: '0.5rem 0.875rem',
|
|
283
|
+
marginTop: '0.75rem',
|
|
284
|
+
cursor: 'pointer',
|
|
285
|
+
};
|
|
286
|
+
return (
|
|
287
|
+
<section style={style}>
|
|
288
|
+
{children}
|
|
289
|
+
{/* No #hex, no 15px — only token references. A rebrand never touches this file. */}
|
|
290
|
+
<button type="button" style={ctaStyle}>
|
|
291
|
+
Continue
|
|
292
|
+
</button>
|
|
293
|
+
</section>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// To mount the themes once at the app root (Next.js: in a Server Component
|
|
298
|
+
// layout, or a root <style> in app/layout.tsx):
|
|
299
|
+
//
|
|
300
|
+
// const css = tokensToCss({ default: lightTheme, overrides: { dark: darkTheme } });
|
|
301
|
+
// // <style dangerouslySetInnerHTML={{ __html: css }} />
|
|
302
|
+
// // then: <html data-theme={prefersDark ? 'dark' : undefined}> ... </html>
|
|
303
|
+
//
|
|
304
|
+
// produces, e.g.:
|
|
305
|
+
// :root { --vf-color-accent: #4f46e5; --vf-type-size-body: 1rem; ... }
|
|
306
|
+
// [data-theme="dark"] { --vf-color-accent: #6366f1; ... }
|
|
307
|
+
|
|
308
|
+
// ── Tailwind adaptation ──────────────────────────────────────────────────────
|
|
309
|
+
// Map semantic tokens into the Tailwind theme as `var(--vf-...)`, so utility
|
|
310
|
+
// classes (`bg-accent`, `text-muted`, `text-body`) resolve to tokens and a
|
|
311
|
+
// theme swap still flows through `data-theme`. Components keep using semantic
|
|
312
|
+
// utilities — never `bg-[#4f46e5]`.
|
|
313
|
+
//
|
|
314
|
+
// // tailwind.config.ts
|
|
315
|
+
// export default {
|
|
316
|
+
// theme: {
|
|
317
|
+
// extend: {
|
|
318
|
+
// colors: {
|
|
319
|
+
// canvas: 'var(--vf-color-bg-canvas)',
|
|
320
|
+
// raised: 'var(--vf-color-bg-raised)',
|
|
321
|
+
// accent: { DEFAULT: 'var(--vf-color-accent)', hover: 'var(--vf-color-accent-hover)' },
|
|
322
|
+
// muted: 'var(--vf-color-text-muted)',
|
|
323
|
+
// },
|
|
324
|
+
// fontSize: {
|
|
325
|
+
// caption: 'var(--vf-type-size-caption)',
|
|
326
|
+
// body: 'var(--vf-type-size-body)',
|
|
327
|
+
// heading: 'var(--vf-type-size-heading)',
|
|
328
|
+
// },
|
|
329
|
+
// },
|
|
330
|
+
// },
|
|
331
|
+
// };
|
|
332
|
+
//
|
|
333
|
+
// Lint guardrail (field report #351): forbid raw hex/px in component source so
|
|
334
|
+
// the indirection can't be bypassed. Stylelint: `color-no-hex` + a custom
|
|
335
|
+
// `declaration-property-value-disallowed-list` for `font-size: /\d+px/`; for
|
|
336
|
+
// className strings, an ESLint `no-restricted-syntax` rule banning Tailwind
|
|
337
|
+
// arbitrary-value brackets in color/size utilities (e.g. `bg-[#...]`,
|
|
338
|
+
// `text-[15px]`). The token layer only pays off if primitives can't leak past it.
|