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 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.1","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"}}
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"}}
@@ -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 (!fs.existsSync(cursorDir)) return;
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 (!fs.existsSync(cursorDir)) return;
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
- ensureCursorSkills(targetRoot, dryRun);
322
- ensureCursorDirSymlink(targetRoot, "agents", "agents", dryRun, "package agents");
323
- ensureCursorDirSymlink(targetRoot, "commands", "commands", dryRun, "package commands");
324
- ensureCursorDirSymlink(targetRoot, "references", "references", dryRun, "package references");
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