compound-workflow 1.1.1 → 1.2.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/README.md +4 -2
- package/package.json +1 -1
- package/scripts/install-cli.mjs +93 -17
- package/src/.agents/skills/standards/SKILL.md +705 -0
- package/src/AGENTS.md +2 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ npx compound-workflow install
|
|
|
29
29
|
|
|
30
30
|
**What Install does:** Merges `AGENTS.md` (preserves your Repo Config Block), creates standard dirs (`docs/`, `todos/`), writes `opencode.json` (for OpenCode), and—if the project has a `.cursor` directory—creates `.cursor/skills/<skill>`, `.cursor/agents`, `.cursor/commands`, and `.cursor/references` (symlinks into the package). All paths reference `node_modules/compound-workflow`; no file copying.
|
|
31
31
|
|
|
32
|
-
**CLI options:** `--dry-run` (preview), `--root /path/to/project`, `--no-config` (skip Repo Config Block reminder).
|
|
32
|
+
**CLI options:** `--dry-run` (preview), `--root /path/to/project`, `--no-config` (skip Repo Config Block reminder), `--cursor` (create `.cursor` and wire Cursor symlinks even if `.cursor` does not already exist).
|
|
33
33
|
|
|
34
34
|
**Legacy (clone inside repo):** If you cloned this repo inside a host repo and need to copy files without npm, use `./scripts/sync-into-repo.sh` (copy only; does not update opencode.json). Prefer the npm + Install flow above.
|
|
35
35
|
|
|
@@ -131,7 +131,7 @@ Full detail: [src/AGENTS.md](src/AGENTS.md), [src/.agents/commands/](src/.agents
|
|
|
131
131
|
|
|
132
132
|
Commands are the public API. Skills and agents are invoked by commands; you don’t call them directly.
|
|
133
133
|
|
|
134
|
-
- **Workflow skills:** `brainstorming`, `file-todos`, `compound-docs`, `document-review`, `technical-review`, `git-worktree`, `agent-browser`, `process-metrics`, `react-ddd-mvc-frontend`, `xstate-actor-orchestration`.
|
|
134
|
+
- **Workflow skills:** `brainstorming`, `file-todos`, `compound-docs`, `document-review`, `technical-review`, `git-worktree`, `agent-browser`, `process-metrics`, `react-ddd-mvc-frontend`, `xstate-actor-orchestration`, `standards`.
|
|
135
135
|
- **State orchestration:** Use a state-orchestration skill when complexity exceeds simple local state (e.g. `xstate-actor-orchestration` per Skill Index)—UI container-as-orchestrator flows, backend/internal actor orchestration, receptionist/child-actor patterns, retries/timeouts/cancellation, or boolean-flag sprawl.
|
|
136
136
|
- **Skill-local metadata:** Some skills may include tool-specific metadata under `src/.agents/skills/<skill>/agents/` (for example `openai.yaml`) when required by skill validation/runtime.
|
|
137
137
|
- **Guardrail standards:** `data-foundations`, `pii-protection-prisma`, `financial-workflow-integrity`, `audit-traceability` — applied when work touches multi-tenant data, PII, money, or audit.
|
|
@@ -159,6 +159,8 @@ Full “when to use what” and reference standards: [src/AGENTS.md](src/AGENTS.
|
|
|
159
159
|
|
|
160
160
|
**Skills not showing in Cursor?** Cursor discovers skills from (1) the plugin’s `skills/` directory when you load the plugin from this repo, or (2) the project’s `.cursor/skills/` when you use npm: ensure the project has a `.cursor` directory and run `npx compound-workflow install`—Install creates the full structure (`.cursor/skills/<skill>`, `.cursor/agents`, `.cursor/commands`, `.cursor/references`). If skills still don’t appear, check Cursor Settings → Rules and any `permission.skill` settings.
|
|
161
161
|
|
|
162
|
+
If your project does not already have `.cursor/`, run `npx compound-workflow install --cursor` to create it and wire links. Install now fails fast when existing non-symlink files/directories block `.cursor` link creation, to prevent partial installs and command drift.
|
|
163
|
+
|
|
162
164
|
**Skills not showing in OpenCode?** OpenCode uses the `.agents/compound-workflow-skills` symlink and `opencode.json` `skills.paths`. Run Install from the project root (`npx compound-workflow install`). The learnings-capture skill is named **compound-docs** (hyphen, plural); **compound_doc** (underscore) is an alias that resolves to the same skill.
|
|
163
165
|
|
|
164
166
|
---
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"compound-workflow","version":"1.
|
|
1
|
+
{"name":"compound-workflow","version":"1.2.0","description":"Clarify → plan → execute → verify → capture. One Install action for Cursor, Claude, and OpenCode.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/cjerochim/compound-workflow.git"},"bin":{"compound-workflow":"scripts/install-cli.mjs"},"files":["src","scripts",".cursor-plugin",".claude-plugin","skills"],"scripts":{"check:pack-readme":"node scripts/check-pack-readme.mjs"},"engines":{"node":">=18"},"devDependencies":{"@semantic-release/git":"^10.0.1","@semantic-release/npm":"^13.1.4","semantic-release":"^25.0.3"}}
|
package/scripts/install-cli.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
14
14
|
function usage(exitCode = 0) {
|
|
15
15
|
const msg = `
|
|
16
16
|
Usage:
|
|
17
|
-
npx compound-workflow install [--root <projectDir>] [--dry-run] [--no-config]
|
|
17
|
+
npx compound-workflow install [--root <projectDir>] [--dry-run] [--no-config] [--cursor]
|
|
18
18
|
|
|
19
19
|
One action: writes opencode.json (loads from package), merges AGENTS.md, creates dirs,
|
|
20
20
|
and prompts for Repo Config Block (unless --no-config).
|
|
@@ -22,17 +22,19 @@ and prompts for Repo Config Block (unless --no-config).
|
|
|
22
22
|
--root <dir> Project directory (default: cwd)
|
|
23
23
|
--dry-run Print planned changes only
|
|
24
24
|
--no-config Skip Repo Config Block prompt (only write opencode.json + AGENTS.md + dirs)
|
|
25
|
+
--cursor Force Cursor integration (create .cursor if missing, then wire links)
|
|
25
26
|
`;
|
|
26
27
|
(exitCode === 0 ? console.log : console.error)(msg.trimStart());
|
|
27
28
|
process.exit(exitCode);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function parseArgs(argv) {
|
|
31
|
-
const out = { root: process.cwd(), dryRun: false, noConfig: false };
|
|
32
|
+
const out = { root: process.cwd(), dryRun: false, noConfig: false, cursor: false };
|
|
32
33
|
for (let i = 2; i < argv.length; i++) {
|
|
33
34
|
const a = argv[i];
|
|
34
35
|
if (a === "--dry-run") out.dryRun = true;
|
|
35
36
|
else if (a === "--no-config") out.noConfig = true;
|
|
37
|
+
else if (a === "--cursor") out.cursor = true;
|
|
36
38
|
else if (a === "--root") {
|
|
37
39
|
const v = argv[i + 1];
|
|
38
40
|
if (!v) usage(1);
|
|
@@ -233,11 +235,11 @@ function ensureSkillsSymlink(targetRoot, dryRun) {
|
|
|
233
235
|
}
|
|
234
236
|
}
|
|
235
237
|
|
|
236
|
-
function ensureCursorDirSymlink(targetRoot, cursorSubdir, pkgSubdir, dryRun, label) {
|
|
238
|
+
function ensureCursorDirSymlink(targetRoot, cursorSubdir, pkgSubdir, dryRun, label, cursorReady) {
|
|
237
239
|
const cursorDir = path.join(targetRoot, ".cursor");
|
|
238
|
-
if (!
|
|
240
|
+
if (!cursorReady) return { status: "skipped-missing-cursor" };
|
|
239
241
|
const pkgPath = path.join(packageRoot, "src", ".agents", pkgSubdir);
|
|
240
|
-
if (!fs.existsSync(pkgPath)) return;
|
|
242
|
+
if (!fs.existsSync(pkgPath)) return { status: "skipped-missing-package-path" };
|
|
241
243
|
|
|
242
244
|
const linkPath = path.join(cursorDir, cursorSubdir);
|
|
243
245
|
const targetRel = path.join("..", "node_modules", "compound-workflow", "src", ".agents", pkgSubdir);
|
|
@@ -245,7 +247,7 @@ function ensureCursorDirSymlink(targetRoot, cursorSubdir, pkgSubdir, dryRun, lab
|
|
|
245
247
|
|
|
246
248
|
if (dryRun) {
|
|
247
249
|
console.log("[dry-run] Would create .cursor/" + cursorSubdir, "symlink (Cursor)");
|
|
248
|
-
return;
|
|
250
|
+
return { status: "dry-run" };
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
let needCreate = true;
|
|
@@ -254,7 +256,7 @@ function ensureCursorDirSymlink(targetRoot, cursorSubdir, pkgSubdir, dryRun, lab
|
|
|
254
256
|
if (stat.isSymbolicLink() && symlinkPointsTo(linkPath, targetAbs)) needCreate = false;
|
|
255
257
|
else if (!stat.isSymbolicLink()) {
|
|
256
258
|
console.warn("Skipped", ".cursor/" + cursorSubdir, "because it exists and is not a symlink");
|
|
257
|
-
return;
|
|
259
|
+
return { status: "blocked-nonsymlink", path: linkPath };
|
|
258
260
|
}
|
|
259
261
|
} catch (_) {}
|
|
260
262
|
|
|
@@ -263,15 +265,17 @@ function ensureCursorDirSymlink(targetRoot, cursorSubdir, pkgSubdir, dryRun, lab
|
|
|
263
265
|
const type = process.platform === "win32" ? "dir" : "dir";
|
|
264
266
|
fs.symlinkSync(targetRel, linkPath, type);
|
|
265
267
|
console.log("Created", ".cursor/" + cursorSubdir, "->", label || pkgSubdir, "(Cursor)");
|
|
268
|
+
return { status: "created" };
|
|
266
269
|
}
|
|
270
|
+
return { status: "ok" };
|
|
267
271
|
}
|
|
268
272
|
|
|
269
|
-
function ensureCursorSkills(targetRoot, dryRun) {
|
|
273
|
+
function ensureCursorSkills(targetRoot, dryRun, cursorReady) {
|
|
270
274
|
const cursorDir = path.join(targetRoot, ".cursor");
|
|
271
|
-
if (!
|
|
275
|
+
if (!cursorReady) return { blocked: [] };
|
|
272
276
|
|
|
273
277
|
const packageSkillsDir = path.join(packageRoot, "src", ".agents", "skills");
|
|
274
|
-
if (!fs.existsSync(packageSkillsDir)) return;
|
|
278
|
+
if (!fs.existsSync(packageSkillsDir)) return { blocked: [] };
|
|
275
279
|
|
|
276
280
|
const skillNames = [];
|
|
277
281
|
try {
|
|
@@ -290,11 +294,19 @@ function ensureCursorSkills(targetRoot, dryRun) {
|
|
|
290
294
|
|
|
291
295
|
if (dryRun) {
|
|
292
296
|
console.log("[dry-run] Would create .cursor/skills/<skill> symlinks for:", skillNames.join(", "));
|
|
293
|
-
return;
|
|
297
|
+
return { blocked: [] };
|
|
294
298
|
}
|
|
295
299
|
|
|
296
300
|
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true });
|
|
301
|
+
else {
|
|
302
|
+
const stat = fs.lstatSync(skillsDir);
|
|
303
|
+
if (!stat.isDirectory()) {
|
|
304
|
+
console.warn("Skipped .cursor/skills because it exists and is not a directory");
|
|
305
|
+
return { blocked: [skillsDir] };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
297
308
|
|
|
309
|
+
const blocked = [];
|
|
298
310
|
for (const name of skillNames) {
|
|
299
311
|
const linkPath = path.join(skillsDir, name);
|
|
300
312
|
const targetRel = path.join("..", "..", "..", "node_modules", "compound-workflow", "src", ".agents", "skills", name);
|
|
@@ -305,6 +317,7 @@ function ensureCursorSkills(targetRoot, dryRun) {
|
|
|
305
317
|
if (stat.isSymbolicLink() && symlinkPointsTo(linkPath, targetAbs)) needCreate = false;
|
|
306
318
|
else if (!stat.isSymbolicLink()) {
|
|
307
319
|
console.warn("Skipped", ".cursor/skills/" + name, "because it exists and is not a symlink");
|
|
320
|
+
blocked.push(linkPath);
|
|
308
321
|
continue;
|
|
309
322
|
}
|
|
310
323
|
} catch (_) {}
|
|
@@ -315,13 +328,67 @@ function ensureCursorSkills(targetRoot, dryRun) {
|
|
|
315
328
|
console.log("Created", ".cursor/skills/" + name, "-> package skill (Cursor)");
|
|
316
329
|
}
|
|
317
330
|
}
|
|
331
|
+
return { blocked };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function verifyCursorIntegration(targetRoot) {
|
|
335
|
+
const cursorDir = path.join(targetRoot, ".cursor");
|
|
336
|
+
if (!fs.existsSync(cursorDir)) return [];
|
|
337
|
+
|
|
338
|
+
const checks = [
|
|
339
|
+
{ name: ".cursor/agents", rel: path.join("agents") },
|
|
340
|
+
{ name: ".cursor/commands", rel: path.join("commands") },
|
|
341
|
+
{ name: ".cursor/references", rel: path.join("references") },
|
|
342
|
+
];
|
|
343
|
+
const issues = [];
|
|
344
|
+
|
|
345
|
+
for (const check of checks) {
|
|
346
|
+
const linkPath = path.join(cursorDir, check.rel);
|
|
347
|
+
const expectedAbs = path.join(targetRoot, "node_modules", "compound-workflow", "src", ".agents", check.rel);
|
|
348
|
+
if (!symlinkPointsTo(linkPath, expectedAbs)) issues.push(`${check.name} is missing or not linked to package`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const workCommand = path.join(cursorDir, "commands", "workflow", "work.md");
|
|
352
|
+
if (!fs.existsSync(workCommand)) {
|
|
353
|
+
issues.push(".cursor/commands/workflow/work.md is not reachable after integration");
|
|
354
|
+
}
|
|
355
|
+
return issues;
|
|
318
356
|
}
|
|
319
357
|
|
|
320
|
-
function ensureCursorIntegration(targetRoot, dryRun) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
358
|
+
function ensureCursorIntegration(targetRoot, dryRun, forceCursor) {
|
|
359
|
+
const cursorDir = path.join(targetRoot, ".cursor");
|
|
360
|
+
let cursorReady = fs.existsSync(cursorDir);
|
|
361
|
+
if (!fs.existsSync(cursorDir)) {
|
|
362
|
+
if (!forceCursor) return { issues: [] };
|
|
363
|
+
if (dryRun) console.log("[dry-run] Would create .cursor directory (Cursor)");
|
|
364
|
+
else {
|
|
365
|
+
fs.mkdirSync(cursorDir, { recursive: true });
|
|
366
|
+
cursorReady = true;
|
|
367
|
+
}
|
|
368
|
+
if (dryRun) cursorReady = true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const skillReport = ensureCursorSkills(targetRoot, dryRun, cursorReady);
|
|
372
|
+
const dirReports = [
|
|
373
|
+
ensureCursorDirSymlink(targetRoot, "agents", "agents", dryRun, "package agents", cursorReady),
|
|
374
|
+
ensureCursorDirSymlink(targetRoot, "commands", "commands", dryRun, "package commands", cursorReady),
|
|
375
|
+
ensureCursorDirSymlink(targetRoot, "references", "references", dryRun, "package references", cursorReady),
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
const issues = [];
|
|
379
|
+
if (skillReport?.blocked?.length) {
|
|
380
|
+
for (const p of skillReport.blocked) issues.push(`${path.relative(targetRoot, p)} blocks symlink creation (not a symlink)`);
|
|
381
|
+
}
|
|
382
|
+
for (const report of dirReports) {
|
|
383
|
+
if (report?.status === "blocked-nonsymlink") {
|
|
384
|
+
issues.push(`${path.relative(targetRoot, report.path)} blocks symlink creation (not a symlink)`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!dryRun) {
|
|
389
|
+
for (const issue of verifyCursorIntegration(targetRoot)) issues.push(issue);
|
|
390
|
+
}
|
|
391
|
+
return { issues };
|
|
325
392
|
}
|
|
326
393
|
|
|
327
394
|
function writeOpenCodeJson(targetRoot, dryRun) {
|
|
@@ -472,10 +539,19 @@ function main() {
|
|
|
472
539
|
writeOpenCodeJson(targetRoot, args.dryRun);
|
|
473
540
|
ensureSkillsSymlink(targetRoot, args.dryRun);
|
|
474
541
|
reportOpenCodeIntegration(targetRoot, args.dryRun);
|
|
475
|
-
ensureCursorIntegration(targetRoot, args.dryRun);
|
|
542
|
+
const cursorReport = ensureCursorIntegration(targetRoot, args.dryRun, args.cursor);
|
|
476
543
|
writeAgentsMd(targetRoot, args.dryRun);
|
|
477
544
|
ensureDirs(targetRoot, args.dryRun);
|
|
478
545
|
|
|
546
|
+
if (cursorReport.issues.length) {
|
|
547
|
+
console.error("\nCursor integration drift detected:");
|
|
548
|
+
for (const issue of cursorReport.issues) console.error("-", issue);
|
|
549
|
+
if (!args.dryRun) {
|
|
550
|
+
console.error("Fix blockers (or remove conflicting paths) and rerun install.");
|
|
551
|
+
process.exit(2);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
479
555
|
if (!args.noConfig && !args.dryRun && process.stdin.isTTY) {
|
|
480
556
|
console.log("\nRepo Config: edit AGENTS.md to set default_branch, test_command, lint_command, dev_server_url.");
|
|
481
557
|
}
|
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: standards
|
|
3
|
+
description: General coding practices, implementation styles, and patterns for the Altai application. Covers domain entities, XState patterns, type usage, and code organization. Use when implementing features, writing new code, or refactoring existing code in the Altai codebase.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Altai Code Standards
|
|
7
|
+
|
|
8
|
+
## Core Principles
|
|
9
|
+
|
|
10
|
+
1. **Simplicity over cleverness** - Prefer straightforward solutions
|
|
11
|
+
2. **Maintainability over flexibility** - Avoid premature abstraction
|
|
12
|
+
3. **YAGNI** - Add complexity when needed, not before
|
|
13
|
+
4. **Domain entities for logic** - Use pure functions in entities for transforms and predicates
|
|
14
|
+
5. **Keep controllers simple** - Delegate complexity to domain entities
|
|
15
|
+
6. **Early exits over else/else-if** - Return early for special cases, avoid nested conditionals
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Domain Entities
|
|
20
|
+
|
|
21
|
+
Location: `src/features/{feature}/domain/entities/`
|
|
22
|
+
|
|
23
|
+
Domain entities contain **types** and **pure functions** that encapsulate business logic. Use them for transforms, predicates, and any complex logic - keeping controllers/machines simple.
|
|
24
|
+
|
|
25
|
+
### Structure
|
|
26
|
+
|
|
27
|
+
- Pure functions, no classes
|
|
28
|
+
- One file per concern (e.g., `PlaylistSelectionEntity.ts` vs `PlaylistReorderEntity.ts`)
|
|
29
|
+
- Private helpers stay internal (no `export`)
|
|
30
|
+
|
|
31
|
+
### Function Signatures
|
|
32
|
+
|
|
33
|
+
**Use named params for 2+ arguments:**
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// Good - clear at call site
|
|
37
|
+
export const hasAddedPlaylists = ({
|
|
38
|
+
current,
|
|
39
|
+
incoming,
|
|
40
|
+
}: PlaylistCompareParams): boolean => {
|
|
41
|
+
// ...
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Usage
|
|
45
|
+
hasAddedPlaylists({ current: ctx.playlists, incoming: evt.payload.playlists });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Benefits**: Self-documenting, easier to refactor, better IDE support.
|
|
49
|
+
|
|
50
|
+
### Naming Conventions
|
|
51
|
+
|
|
52
|
+
| Prefix | Purpose | Example |
|
|
53
|
+
| ------ | --------------------- | ------------------------------------------ |
|
|
54
|
+
| `hasX` | Boolean predicate | `hasAddedPlaylists`, `hasRemovedPlaylists` |
|
|
55
|
+
| `getX` | Retrieve/extract data | `getAddedPlaylists`, `getRemovedPlaylists` |
|
|
56
|
+
| `toX` | Transform data | `toPlaylistIds`, `toSortedPlaylistIds` |
|
|
57
|
+
|
|
58
|
+
### Early Exit Pattern
|
|
59
|
+
|
|
60
|
+
**Always prefer early exits over else/else-if** - this is a code standard, not a suggestion.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// ❌ Bad - nested conditionals with else-if
|
|
64
|
+
export const createAction = (params) => {
|
|
65
|
+
if (params.pendingFolder) {
|
|
66
|
+
return {
|
|
67
|
+
metadata: {
|
|
68
|
+
createFolder: params.pendingFolder,
|
|
69
|
+
mode: "create",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
} else if (params.existingFolder) {
|
|
73
|
+
return {
|
|
74
|
+
metadata: {
|
|
75
|
+
targetFolder: params.existingFolder,
|
|
76
|
+
mode: "existing",
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
} else {
|
|
80
|
+
throw new Error("Invalid params");
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ✅ Good - early exits, flat structure
|
|
85
|
+
export const createAction = (params) => {
|
|
86
|
+
// Handle special case first
|
|
87
|
+
if (params.pendingFolder) {
|
|
88
|
+
return {
|
|
89
|
+
metadata: {
|
|
90
|
+
createFolder: params.pendingFolder,
|
|
91
|
+
mode: "create",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Handle next case
|
|
97
|
+
if (params.existingFolder) {
|
|
98
|
+
return {
|
|
99
|
+
metadata: {
|
|
100
|
+
targetFolder: params.existingFolder,
|
|
101
|
+
mode: "existing",
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Invalid state
|
|
107
|
+
throw new Error("Invalid params");
|
|
108
|
+
};
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Benefits**:
|
|
112
|
+
|
|
113
|
+
- **Flat code structure** - No nesting, easier to read
|
|
114
|
+
- **Clear intent** - Each case is independent and complete
|
|
115
|
+
- **Easier to modify** - Add/remove cases without touching others
|
|
116
|
+
- **No else-if** - Else-if is a code smell indicating missed early exit opportunities
|
|
117
|
+
|
|
118
|
+
**When extracting action creation to entities**:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// ✅ Good - early exits for different contexts
|
|
122
|
+
export const createSelectFolderRootAction = ({
|
|
123
|
+
folder,
|
|
124
|
+
activeTabId,
|
|
125
|
+
activeFolderId,
|
|
126
|
+
pendingFolderName,
|
|
127
|
+
}: Params): PanelActionEntity => {
|
|
128
|
+
// Early exit - pending folder
|
|
129
|
+
if (pendingFolderName && activeTabId) {
|
|
130
|
+
return panelActionEntity.toActionItem(ActionTypes.SHARE, {
|
|
131
|
+
label: pendingFolderName,
|
|
132
|
+
metadata: {
|
|
133
|
+
targetTabId: activeTabId,
|
|
134
|
+
createFolderName: pendingFolderName,
|
|
135
|
+
mode: "folderRoot",
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Early exit - existing folder in share context
|
|
141
|
+
if (activeTabId && activeFolderId) {
|
|
142
|
+
return panelActionEntity.toActionItem(ActionTypes.SHARE, {
|
|
143
|
+
label: folder?.label ?? "Folder",
|
|
144
|
+
metadata: {
|
|
145
|
+
targetTabId: activeTabId,
|
|
146
|
+
targetFolderId: activeFolderId,
|
|
147
|
+
mode: "folderRoot",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Early exit - move to folder context
|
|
153
|
+
if (activeFolderId) {
|
|
154
|
+
return panelActionEntity.toActionItem(ActionTypes.MOVE, {
|
|
155
|
+
label: folder?.label ?? "Folder",
|
|
156
|
+
metadata: {
|
|
157
|
+
targetFolderId: activeFolderId,
|
|
158
|
+
mode: "folderRoot",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw new Error("Invalid parameters");
|
|
164
|
+
};
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Anti-patterns to avoid**:
|
|
168
|
+
|
|
169
|
+
- `else` and `else-if` blocks
|
|
170
|
+
- Conditional spreading: `...(condition && { prop: value })`
|
|
171
|
+
- Let variables modified in conditionals
|
|
172
|
+
- Nested ternaries for complex logic
|
|
173
|
+
|
|
174
|
+
### When to Use
|
|
175
|
+
|
|
176
|
+
- **Predicates**: `hasAddedPlaylists`, `isValidState`
|
|
177
|
+
- **Transforms**: `toPlaylistIds`, `toSortedItems`
|
|
178
|
+
- **Comparisons**: Diffing arrays, checking membership
|
|
179
|
+
- **Complex conditionals**: Extract to helper functions
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// Extract complex logic to helpers
|
|
183
|
+
const getFolderLabel = (
|
|
184
|
+
folder: InputOption | ClipsPanelFolderEntity
|
|
185
|
+
): string => {
|
|
186
|
+
return "label" in folder ? folder.label : (folder.folderName ?? "Folder");
|
|
187
|
+
};
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
This keeps controllers/machines simple - they delegate logic to entities.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## XState Patterns
|
|
195
|
+
|
|
196
|
+
### Guards (Conditions)
|
|
197
|
+
|
|
198
|
+
Extract complex guards to domain entity predicates. Inline the entity call in `cond`:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import * as playlistSelectionEntity from "src/features/tapesv3/domain/entities/PlaylistSelectionEntity";
|
|
202
|
+
|
|
203
|
+
[EventType.PLAYLIST_CHANGED]: [
|
|
204
|
+
{
|
|
205
|
+
cond: (ctx, evt) =>
|
|
206
|
+
playlistSelectionEntity.hasAddedPlaylists({
|
|
207
|
+
current: ctx.playlists,
|
|
208
|
+
incoming: evt.payload.playlists,
|
|
209
|
+
}),
|
|
210
|
+
actions: [/* ... */],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Avoid unnecessary wrapper functions - inline entity calls directly.
|
|
216
|
+
|
|
217
|
+
### State vs Context: Model UI Modes as State
|
|
218
|
+
|
|
219
|
+
**Use state machines to model UI modes** - don't store mode flags in context:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// Bad - storing mode in context
|
|
223
|
+
type Context = {
|
|
224
|
+
viewMode: "folder" | "search"; // Don't store what state can represent
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Good - model modes as parallel states
|
|
228
|
+
viewModeManagement: {
|
|
229
|
+
initial: "folderView",
|
|
230
|
+
states: {
|
|
231
|
+
folderView: {
|
|
232
|
+
on: {
|
|
233
|
+
[EventType.SYNC_VIEW_MODE]: {
|
|
234
|
+
cond: (_, evt) => evt.payload.viewMode === "search",
|
|
235
|
+
target: "searchView",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
searchView: { /* ... */ },
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Rationale**: State machines semantically represent modes. Context holds data, not state.
|
|
245
|
+
|
|
246
|
+
**Exception**: Storing previous values in context for business logic comparison is acceptable:
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
// Acceptable - storing previous value for change detection
|
|
250
|
+
type Context = {
|
|
251
|
+
oldHierarchy: Hierarchy | null; // For comparing with newHierarchy
|
|
252
|
+
viewMode: "folder" | "search"; // For comparing previous vs current in change detection
|
|
253
|
+
};
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The distinction: Don't store current UI state in context when it can be modeled as machine state. Do store previous values when needed for comparison logic (change detection, diffing, etc.).
|
|
257
|
+
|
|
258
|
+
### Event & Type Patterns
|
|
259
|
+
|
|
260
|
+
**Consolidate similar events with properties:**
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
// Bad - event proliferation
|
|
264
|
+
BROADCAST_FOLDER_VIEW_MODE;
|
|
265
|
+
BROADCAST_SEARCH_VIEW_MODE;
|
|
266
|
+
|
|
267
|
+
// Good - single event with property
|
|
268
|
+
BROADCAST_VIEW_MODE: {
|
|
269
|
+
payload: {
|
|
270
|
+
viewMode: "folder" | "search";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Move reusable types to domain entities:**
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
// Domain entity: FolderSelectorEntity.ts
|
|
279
|
+
export type FolderSelectorViewMode = "folder" | "search";
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Single source of truth, consistent types across controllers/containers.
|
|
283
|
+
|
|
284
|
+
### assertEvent Usage
|
|
285
|
+
|
|
286
|
+
**Only use `assertEvent` when TypeScript can't narrow the event type:**
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
// Bad - redundant, TypeScript already knows the type from transition
|
|
290
|
+
[EventType.SYNC_VIEW_MODE]: {
|
|
291
|
+
actions: assign({
|
|
292
|
+
viewMode: (_, evt) => {
|
|
293
|
+
assertEvent(evt, EventType.SYNC_VIEW_MODE); // Unnecessary
|
|
294
|
+
return evt.payload.viewMode;
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Good - no assertEvent needed in assign actions
|
|
300
|
+
[EventType.SYNC_VIEW_MODE]: {
|
|
301
|
+
actions: assign({
|
|
302
|
+
viewMode: (_, evt) => evt.payload.viewMode,
|
|
303
|
+
}),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Good - assertEvent needed in broadcast payload functions
|
|
307
|
+
broadcast({
|
|
308
|
+
type: PublicEventType.BROADCAST_VIEW_MODE,
|
|
309
|
+
payload: (ctx, evt) => {
|
|
310
|
+
assertEvent(evt, EventType.SYNC_VIEW_MODE); // Needed - type not narrowed
|
|
311
|
+
return { viewMode: evt.payload.viewMode };
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Rationale**: In `assign` actions, the event type is already narrowed by the transition. In `broadcast` payload functions, the event type isn't narrowed, so `assertEvent` provides runtime type safety.
|
|
317
|
+
|
|
318
|
+
### Handler Placement
|
|
319
|
+
|
|
320
|
+
**Event handlers live in the state that manages that concern** - not in parallel states or background sync:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// Good - handler in the state managing search view
|
|
324
|
+
viewModeManagement: {
|
|
325
|
+
states: {
|
|
326
|
+
searchView: {
|
|
327
|
+
on: {
|
|
328
|
+
[EventType.SYNC_BATCH_SEARCH_RESULTS]: {
|
|
329
|
+
actions: [/* Handle search results here */],
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Keeps concerns separated, avoids "half jobs" split across states.
|
|
338
|
+
|
|
339
|
+
### Container State Management
|
|
340
|
+
|
|
341
|
+
**When moving state to parent, remove all fallback logic** - make props required:
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
// Good - fully controlled by parent
|
|
345
|
+
type ContainerProps = {
|
|
346
|
+
viewMode: ViewMode; // Required
|
|
347
|
+
onViewModeChange: (viewMode: ViewMode) => void; // Required
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Parent manages state
|
|
351
|
+
const [viewMode, setViewMode] = useState<ViewMode>("folder");
|
|
352
|
+
<Container viewMode={viewMode} onViewModeChange={setViewMode} />
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Eliminates dual sources of truth, makes data flow explicit.
|
|
356
|
+
|
|
357
|
+
### Entity Imports
|
|
358
|
+
|
|
359
|
+
Import as namespace for clarity:
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
import * as playlistSelectionEntity from "...";
|
|
363
|
+
playlistSelectionEntity.hasAddedPlaylists({ ... });
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## Import Paths
|
|
369
|
+
|
|
370
|
+
Use absolute paths from `src/`:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
import { InputOption } from "src/features/common/domain/entities/SelectionInputEntity";
|
|
374
|
+
import * as playlistSelectionEntity from "src/features/tapesv3/domain/entities/PlaylistSelectionEntity";
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Relative paths acceptable within same feature for deeply nested files.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## File Organization
|
|
382
|
+
|
|
383
|
+
### Feature Structure
|
|
384
|
+
|
|
385
|
+
```
|
|
386
|
+
src/features/{feature}/
|
|
387
|
+
├── application/
|
|
388
|
+
│ ├── containers/ # React containers (connect to XState)
|
|
389
|
+
│ └── controllers/ # XState machines
|
|
390
|
+
├── domain/
|
|
391
|
+
│ └── entities/ # Pure functions, types, business logic
|
|
392
|
+
├── presentation/ # React components (UI only)
|
|
393
|
+
└── types.ts # Shared types, event enums
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### When to Create New Files
|
|
397
|
+
|
|
398
|
+
- **New entity file**: When the concern is distinct (e.g., selection vs reordering)
|
|
399
|
+
- **Same file**: When functions are tightly related and small
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Container / Controller Separation
|
|
404
|
+
|
|
405
|
+
### Containers
|
|
406
|
+
|
|
407
|
+
Location: `src/features/{feature}/application/containers/`
|
|
408
|
+
|
|
409
|
+
Containers wire controllers to presentation components. They do NOT contain business logic.
|
|
410
|
+
|
|
411
|
+
**Responsibilities:**
|
|
412
|
+
|
|
413
|
+
- Get actor ref via `useActorRefById`
|
|
414
|
+
- Select UI-relevant data via `useSelector`
|
|
415
|
+
- Create callbacks that send events to controller
|
|
416
|
+
- Compose presentation components
|
|
417
|
+
- Apply `useMemo` for derived UI values if needed
|
|
418
|
+
|
|
419
|
+
**Do NOT:**
|
|
420
|
+
|
|
421
|
+
- Transform data (controller's job)
|
|
422
|
+
- Contain business logic
|
|
423
|
+
- Make decisions about state
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
export const TapesClipsViewHeaderContainer = () => {
|
|
427
|
+
const actor = useActorRefById<TapesClipsViewerHeaderRef>({
|
|
428
|
+
actorId: TapesActorIds.CLIPS_VIEWER_HEADER,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Select UI data from controller
|
|
432
|
+
const { playlists, availablePlaylists } = useSelector(
|
|
433
|
+
actor,
|
|
434
|
+
(s) => ({
|
|
435
|
+
playlists: s.context.playlists,
|
|
436
|
+
availablePlaylists: s.context.availablePlaylists,
|
|
437
|
+
}),
|
|
438
|
+
shallowEquals,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
// Callback sends event - no logic
|
|
442
|
+
const onPlaylistChange = useCallback(
|
|
443
|
+
(playlists: InputOption[]) => {
|
|
444
|
+
actor.send({
|
|
445
|
+
type: TapesClipsViewerHeaderEventType.PLAYLIST_CHANGED,
|
|
446
|
+
payload: { playlists },
|
|
447
|
+
});
|
|
448
|
+
},
|
|
449
|
+
[actor],
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<PanelClips.TapesViewerHeader>
|
|
454
|
+
<MultiSelectorV2Container
|
|
455
|
+
selectedOptions={playlists}
|
|
456
|
+
options={availablePlaylists}
|
|
457
|
+
onChange={onPlaylistChange}
|
|
458
|
+
/>
|
|
459
|
+
</PanelClips.TapesViewerHeader>
|
|
460
|
+
);
|
|
461
|
+
};
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Controllers
|
|
465
|
+
|
|
466
|
+
Location: `src/features/{feature}/application/controllers/`
|
|
467
|
+
|
|
468
|
+
Controllers handle all business rules and data transforms.
|
|
469
|
+
|
|
470
|
+
**Responsibilities:**
|
|
471
|
+
|
|
472
|
+
- Transform incoming data to UI-ready format
|
|
473
|
+
- Handle business logic (predicates, conditions)
|
|
474
|
+
- Broadcast events to other actors
|
|
475
|
+
- Manage state transitions
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
[PublicTapesEventType.BROADCAST_CLIPS_DATA_SYNC]: {
|
|
479
|
+
actions: [
|
|
480
|
+
assign({
|
|
481
|
+
playlists: (ctx, evt) => {
|
|
482
|
+
// Transform here, not in container
|
|
483
|
+
return evt.payload.clipsData?.playlists.map((playlist) => ({
|
|
484
|
+
id: playlist.playlistId,
|
|
485
|
+
label: playlist.playlistTitle ?? "",
|
|
486
|
+
value: playlist.playlistId,
|
|
487
|
+
})) ?? [];
|
|
488
|
+
},
|
|
489
|
+
}),
|
|
490
|
+
],
|
|
491
|
+
},
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
### Data Flow
|
|
495
|
+
|
|
496
|
+
```
|
|
497
|
+
Server Data → Controller (transform) → Context → Container (useSelector) → Presentation
|
|
498
|
+
↑
|
|
499
|
+
User Action → Container (send event) → Controller (business logic)
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Containers, Handlers, and React Query
|
|
503
|
+
|
|
504
|
+
When events trigger handlers that fetch/update data, data flows through React Query and derived state—**never through useState in the container**.
|
|
505
|
+
|
|
506
|
+
**Rules:**
|
|
507
|
+
|
|
508
|
+
1. **No useState for handler-driven data** — Handler results live in React Query cache, not container state
|
|
509
|
+
2. **Queries only in dedicated hooks** — All queries in feature hook (e.g. `useTapesQueries`)
|
|
510
|
+
3. **Handler writes to cache** — Use `queryClient.setQueryData(key, result)`, query hook subscribes
|
|
511
|
+
4. **Trigger only in container** — Callback invokes handler prop, data returns via props from derived state
|
|
512
|
+
5. **Sync in useEffect** — Watch props, sync to actor in `useEffect`
|
|
513
|
+
|
|
514
|
+
**Pipeline:**
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
Event → Container (invoke handler)
|
|
518
|
+
→ Handler (fetch + setQueryData)
|
|
519
|
+
→ Query hook (subscribed to cache)
|
|
520
|
+
→ Derived state (transform)
|
|
521
|
+
→ Container props
|
|
522
|
+
→ useEffect syncs to actor
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Persisting State to localStorage
|
|
528
|
+
|
|
529
|
+
When user preferences persist across sessions, use a localStorage service invoked by the state machine.
|
|
530
|
+
|
|
531
|
+
### Service Pattern
|
|
532
|
+
|
|
533
|
+
Create service in `src/features/{feature}/infrastructure/services/`:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
export type ViewType = "Batch" | "Profile";
|
|
537
|
+
const STORAGE_KEY = "feature:preferenceKey";
|
|
538
|
+
|
|
539
|
+
export const getPreference = (): Promise<ViewType> => {
|
|
540
|
+
return new Promise((resolve) => {
|
|
541
|
+
try {
|
|
542
|
+
if (typeof window === "undefined" || !window.localStorage) {
|
|
543
|
+
resolve("Batch");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
547
|
+
resolve(stored === "Profile" ? "Profile" : "Batch");
|
|
548
|
+
} catch {
|
|
549
|
+
resolve("Batch");
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
export const setPreference = (viewType: ViewType): void => {
|
|
555
|
+
try {
|
|
556
|
+
window.localStorage?.setItem(STORAGE_KEY, viewType);
|
|
557
|
+
} catch {
|
|
558
|
+
// Silently fail
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Machine Hydration Pattern
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
viewManagement: {
|
|
567
|
+
initial: "hydrate",
|
|
568
|
+
states: {
|
|
569
|
+
hydrate: {
|
|
570
|
+
invoke: {
|
|
571
|
+
src: getPreference,
|
|
572
|
+
onDone: [
|
|
573
|
+
{
|
|
574
|
+
target: "profile",
|
|
575
|
+
cond: (_, evt) => evt.data === "Profile", // XState v4 uses evt.data
|
|
576
|
+
},
|
|
577
|
+
{ target: "batch" },
|
|
578
|
+
],
|
|
579
|
+
onError: { target: "batch" },
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
batch: {
|
|
583
|
+
tags: [Tags.VIEW_TYPE_BATCH],
|
|
584
|
+
entry: [broadcast({ type: EventType.BROADCAST_VIEW_TYPE_CHANGED, payload: () => ({ viewType: "Batch" }) })],
|
|
585
|
+
on: {
|
|
586
|
+
[EventType.TOGGLE_VIEW_TYPE]: {
|
|
587
|
+
target: "profile",
|
|
588
|
+
actions: [() => setPreference("Profile")],
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
profile: {
|
|
593
|
+
tags: [Tags.VIEW_TYPE_PROFILE],
|
|
594
|
+
entry: [broadcast({ type: EventType.BROADCAST_VIEW_TYPE_CHANGED, payload: () => ({ viewType: "Profile" }) })],
|
|
595
|
+
on: {
|
|
596
|
+
[EventType.TOGGLE_VIEW_TYPE]: {
|
|
597
|
+
target: "batch",
|
|
598
|
+
actions: [() => setPreference("Batch")],
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
**Key points**: Broadcast on entry for dependent controllers, use `evt.data` for invoke results, guard `window`/`localStorage` for SSR.
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## Error Handling and Validation
|
|
611
|
+
|
|
612
|
+
### Never Suppress Unexpected Outcomes
|
|
613
|
+
|
|
614
|
+
**Always throw errors for unexpected states** - never silent returns:
|
|
615
|
+
|
|
616
|
+
```typescript
|
|
617
|
+
// Bad - silently fails, hides bugs
|
|
618
|
+
if (!playlist) return;
|
|
619
|
+
|
|
620
|
+
// Good - throws error, makes failures visible
|
|
621
|
+
if (!playlist) throw new Error("Playlist not found");
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
Silent failures hide bugs and data inconsistencies.
|
|
625
|
+
|
|
626
|
+
### Validation at Call Sites
|
|
627
|
+
|
|
628
|
+
**Validate at call sites** (controllers) for context-specific checks, then pass validated data to pure functions:
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
// Controller validates
|
|
632
|
+
if (!ctx.activeTabId || !ctx.activeFolderId) {
|
|
633
|
+
throw new Error("Active tab ID and folder ID are required");
|
|
634
|
+
}
|
|
635
|
+
const playlist = ctx.availablePlaylists.find(
|
|
636
|
+
(p) => p.id === evt.payload.playlistId
|
|
637
|
+
);
|
|
638
|
+
if (!playlist) throw new Error("Playlist not found");
|
|
639
|
+
|
|
640
|
+
// Entity receives validated data
|
|
641
|
+
const action = tapesActionEntity.createApplyPlaylistActionForFolder({
|
|
642
|
+
playlist,
|
|
643
|
+
activeTabId: ctx.activeTabId,
|
|
644
|
+
activeFolderId: ctx.activeFolderId,
|
|
645
|
+
});
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
**Entity functions validate**: Input format/type issues, required parameters that can't be validated at call site.
|
|
649
|
+
|
|
650
|
+
**Entity functions do NOT validate**: Objects already validated at call sites, context-specific requirements.
|
|
651
|
+
|
|
652
|
+
### Type Safety: Required vs Optional
|
|
653
|
+
|
|
654
|
+
**Make parameters required when always validated at call sites:**
|
|
655
|
+
|
|
656
|
+
```typescript
|
|
657
|
+
// Good - required, TypeScript enforces
|
|
658
|
+
export const createAction = ({
|
|
659
|
+
playlist,
|
|
660
|
+
activeFolderId, // Required - validated at call site
|
|
661
|
+
}: {
|
|
662
|
+
playlist: InputOption;
|
|
663
|
+
activeFolderId: string;
|
|
664
|
+
}) => {
|
|
665
|
+
// No runtime check needed
|
|
666
|
+
};
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
TypeScript catches missing parameters at compile time, removes redundant runtime validation.
|
|
670
|
+
|
|
671
|
+
### Extracting Action Creation
|
|
672
|
+
|
|
673
|
+
When action creation logic appears in multiple controllers, extract to entity file.
|
|
674
|
+
|
|
675
|
+
**Location**: `src/features/{feature}/actions/{Feature}ActionEntity.ts`
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
// Entity file
|
|
679
|
+
export const createApplyPlaylistAction = ({
|
|
680
|
+
playlist,
|
|
681
|
+
}: {
|
|
682
|
+
playlist: ClipsBatchPlayListTag;
|
|
683
|
+
}): PanelActionEntity => {
|
|
684
|
+
return panelActionEntity.toActionItem(TapesActionTypes.APPLY_PLAYLIST, {
|
|
685
|
+
label: playlist.label,
|
|
686
|
+
referenceId: playlist.id,
|
|
687
|
+
buttonType: "button",
|
|
688
|
+
metadata: { playlistId: playlist.id },
|
|
689
|
+
});
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
// Controller uses entity
|
|
693
|
+
const action = tapesActionEntity.createApplyPlaylistAction({ playlist });
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
Reusable, testable, single source of truth.
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## Future Sections
|
|
701
|
+
|
|
702
|
+
_Add patterns as they emerge:_
|
|
703
|
+
|
|
704
|
+
- Testing patterns
|
|
705
|
+
- API/data fetching patterns
|
package/src/AGENTS.md
CHANGED
|
@@ -157,7 +157,7 @@ worktree_bootstrap_notes:
|
|
|
157
157
|
## Implemented Components (Current Scope)
|
|
158
158
|
|
|
159
159
|
- Commands: `workflow:brainstorm`, `workflow:plan`, `workflow:work`, `workflow:triage`, `workflow:review`, `workflow:compound` (under `.agents/commands/workflow/`), plus `test-browser`, `metrics`, `assess`, `setup`, `sync` (root commands)
|
|
160
|
-
- Skills: `brainstorming`, `document-review`, `technical-review`, `compound-docs` (alias: `compound_doc`), `file-todos`, `agent-browser`, `git-worktree`, `process-metrics`, `react-ddd-mvc-frontend`, `xstate-actor-orchestration`, `pii-protection-prisma`, `financial-workflow-integrity`, `audit-traceability`, `data-foundations`
|
|
160
|
+
- Skills: `brainstorming`, `document-review`, `technical-review`, `compound-docs` (alias: `compound_doc`), `file-todos`, `agent-browser`, `git-worktree`, `process-metrics`, `react-ddd-mvc-frontend`, `xstate-actor-orchestration`, `standards`, `pii-protection-prisma`, `financial-workflow-integrity`, `audit-traceability`, `data-foundations`
|
|
161
161
|
- Agents:
|
|
162
162
|
- `repo-research-analyst`
|
|
163
163
|
- `learnings-researcher`
|
|
@@ -219,6 +219,7 @@ Maintenance:
|
|
|
219
219
|
| `process-metrics` | You want to log and assess session performance and process improvements. |
|
|
220
220
|
| `react-ddd-mvc-frontend` | You need React frontend architecture guidance (DDD + MVC hybrid) during planning or review to enforce feature structure, layer boundaries, composable pure components, container/controller responsibilities, and maintainable patterns. |
|
|
221
221
|
| `xstate-actor-orchestration` | You are evaluating complexity and need explicit state orchestration: React container-as-orchestrator for UI flows, or actor/state-machine orchestration for backend/internal workflows (especially multi-step async branching, retries/timeouts/cancellation, receptionist/child-actor coordination, or boolean-flag sprawl). |
|
|
222
|
+
| `standards` | You need Altai coding standards for implementation and refactoring, including domain entity patterns, XState conventions, type usage, and feature code organization. |
|
|
222
223
|
|
|
223
224
|
### Reference standards (guardrails)
|
|
224
225
|
|