compound-workflow 1.1.1 → 1.3.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/commands/workflow/triage.md +9 -8
- package/src/.agents/commands/workflow/work.md +4 -3
- package/src/.agents/skills/capture-skill/SKILL.md +125 -0
- package/src/.agents/skills/standards/SKILL.md +705 -0
- package/src/AGENTS.md +8 -8
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.3.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
|
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: triage
|
|
3
3
|
invocation: workflow:triage
|
|
4
|
-
description: Triage
|
|
5
|
-
argument-hint: "[optional: todo path, issue id,
|
|
4
|
+
description: Triage and prioritize todo files into an executable ready queue (priority, dependencies, recommended action)
|
|
5
|
+
argument-hint: "[optional: todo path, issue id, status filter ('pending'|'ready'|'active')]"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# /workflow:triage
|
|
9
9
|
|
|
10
|
-
Turn
|
|
10
|
+
Turn todo items into a prioritized executable queue.
|
|
11
11
|
|
|
12
12
|
This command does not implement fixes. It approves and organizes work so `/workflow:work` can execute without ambiguity.
|
|
13
13
|
|
|
14
14
|
## Inputs
|
|
15
15
|
|
|
16
|
-
- Default: triage all
|
|
16
|
+
- Default: triage all active todos under `todos/` (`pending` + `ready`)
|
|
17
17
|
- Optional: a specific todo file path or issue id
|
|
18
18
|
|
|
19
19
|
## Preconditions
|
|
@@ -24,9 +24,9 @@ This command does not implement fixes. It approves and organizes work so `/workf
|
|
|
24
24
|
## Workflow
|
|
25
25
|
|
|
26
26
|
1. Identify the target set:
|
|
27
|
-
- all `todos/*-pending-*.md
|
|
27
|
+
- all active todos (`todos/*-pending-*.md` + `todos/*-ready-*.md`), or
|
|
28
28
|
- the requested todo
|
|
29
|
-
2. For each
|
|
29
|
+
2. For each target todo:
|
|
30
30
|
- read Problem Statement + Findings + Proposed Solutions
|
|
31
31
|
- fill **Recommended Action** (make it executable)
|
|
32
32
|
- set `priority` (`p1|p2|p3`)
|
|
@@ -45,8 +45,9 @@ This command does not implement fixes. It approves and organizes work so `/workf
|
|
|
45
45
|
- **Blocking spikes first:** If a spike unblocks downstream build todos, prioritize approving that spike before its dependents. Do not approve dependent build todos as executable ahead of unresolved blocking spikes.
|
|
46
46
|
- **Parallel spike readiness:** If multiple spike todos are independent (no dependency edges between them), they may be approved together for parallel execution.
|
|
47
47
|
3. Decision:
|
|
48
|
-
- **approve now** -> rename `*-pending-*` -> `*-ready-*` and set frontmatter `status: ready
|
|
49
|
-
- **defer** -> rename `*-pending-*` -> `*-deferred-*` and set frontmatter `status: deferred` (keep priority, typically `p3`). Ensure Recommended Action, Findings, and Work Log have enough context for future reference. Deferred items are not executed until re-triaged to `ready`.
|
|
48
|
+
- **approve now** -> if pending, rename `*-pending-*` -> `*-ready-*` and set frontmatter `status: ready`; if already ready, keep `status: ready` and update priority/dependencies as needed
|
|
49
|
+
- **defer** -> rename `*-pending-*` or `*-ready-*` -> `*-deferred-*` and set frontmatter `status: deferred` (keep priority, typically `p3`). Ensure Recommended Action, Findings, and Work Log have enough context for future reference. Deferred items are not executed until re-triaged to `ready`.
|
|
50
|
+
- **needs rework** -> when a `ready` todo is not executable, rename `*-ready-*` -> `*-pending-*`, set `status: pending`, and record what is missing before re-approval.
|
|
50
51
|
- **blocked follow-up** -> when work returns a blocked todo, keep it as `pending` (rename from `*-ready-*` when needed), add `tags: [blocker]`, and require blocker options + recommendation in Work Log before re-approval.
|
|
51
52
|
4. Output:
|
|
52
53
|
- list approved `ready` todos (blocking spikes first, then other unblocked items)
|
|
@@ -221,8 +221,9 @@ The input must be a plan file path.
|
|
|
221
221
|
|
|
222
222
|
After creating todos:
|
|
223
223
|
|
|
224
|
-
-
|
|
225
|
-
-
|
|
224
|
+
- Run `/workflow:triage` before any implementation work to approve/prioritize the queue for this plan.
|
|
225
|
+
- Do not proceed to Phase 2 until triage completes and execution order is explicit.
|
|
226
|
+
- If triage leaves no unblocked `ready` todos, stop and report pending/deferred/blocked items.
|
|
226
227
|
|
|
227
228
|
### Phase 2: Execute
|
|
228
229
|
|
|
@@ -249,7 +250,7 @@ The input must be a plan file path.
|
|
|
249
250
|
|
|
250
251
|
- If no unblocked `ready` todos remain:
|
|
251
252
|
- summarize remaining `pending`, `deferred` (parked for reference), and blocked items
|
|
252
|
-
-
|
|
253
|
+
- require re-running `/workflow:triage` for pending/blocked prioritization
|
|
253
254
|
- stop (do not invent work)
|
|
254
255
|
|
|
255
256
|
For each task in priority order:
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: capture-skill
|
|
3
|
+
description: Capture learnings, patterns, or workflows from the current conversation into a new or existing skill. Use when the user wants to save what was learned, discovered, or built during a conversation as a reusable skill for future sessions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Capture Skill from Conversation
|
|
7
|
+
|
|
8
|
+
This skill extracts reusable knowledge from the current conversation and stores it as a skill.
|
|
9
|
+
|
|
10
|
+
## Default Policy
|
|
11
|
+
|
|
12
|
+
`capture-skill` uses an overlay model by default:
|
|
13
|
+
|
|
14
|
+
- Treat existing repo-provided skills as immutable unless the user explicitly asks to edit a specific one.
|
|
15
|
+
- Store project-specific refinements in a project overlay skill (for example, `.agents/skills/project-standards/`).
|
|
16
|
+
- Only modify an existing skill when the user clearly says to do so (for example, "update skill X").
|
|
17
|
+
- Promote overlay content into base skills only as an explicit, deliberate follow-up action.
|
|
18
|
+
|
|
19
|
+
## When to Use
|
|
20
|
+
|
|
21
|
+
- The user says "capture this as a skill" or "save this for next time"
|
|
22
|
+
- A reusable workflow, pattern, or piece of domain knowledge emerged
|
|
23
|
+
- The user wants to refine project standards based on this conversation
|
|
24
|
+
- The conversation revealed non-obvious steps, gotchas, or best practices worth preserving
|
|
25
|
+
|
|
26
|
+
## Capture Process
|
|
27
|
+
|
|
28
|
+
### Phase 1: Identify What to Capture
|
|
29
|
+
|
|
30
|
+
Prioritize broad, reusable patterns over one-off implementation details.
|
|
31
|
+
|
|
32
|
+
Look for:
|
|
33
|
+
|
|
34
|
+
1. High-level principles and standards
|
|
35
|
+
2. Multi-step workflows discovered through trial and error
|
|
36
|
+
3. Domain constraints that are not obvious
|
|
37
|
+
4. Gotchas and reliable fixes
|
|
38
|
+
5. Decision rationale that establishes a repeatable rule
|
|
39
|
+
|
|
40
|
+
Summarize the planned capture and confirm with the user before writing.
|
|
41
|
+
|
|
42
|
+
### Phase 2: Check Related Skills and Consolidate
|
|
43
|
+
|
|
44
|
+
Before creating a new skill:
|
|
45
|
+
|
|
46
|
+
1. List existing skills in personal and project locations.
|
|
47
|
+
2. Find related skill scopes.
|
|
48
|
+
3. Decide: consolidate into existing skill, extend an existing skill section, or create a new skill.
|
|
49
|
+
|
|
50
|
+
Consolidate when content overlaps domain, trigger conditions, or workflow usage.
|
|
51
|
+
|
|
52
|
+
### Phase 3: Choose Destination
|
|
53
|
+
|
|
54
|
+
If not already specified:
|
|
55
|
+
|
|
56
|
+
1. Decide whether this is a new skill or an update.
|
|
57
|
+
2. For new captures, decide storage:
|
|
58
|
+
- Personal skill directory for cross-project behavior
|
|
59
|
+
- Project skill directory for project-specific behavior
|
|
60
|
+
3. Apply the default policy:
|
|
61
|
+
- Prefer project overlay skills for refinements
|
|
62
|
+
- Do not mutate repo-provided skills by default
|
|
63
|
+
|
|
64
|
+
### Phase 4: Draft Skill Content
|
|
65
|
+
|
|
66
|
+
For new skills:
|
|
67
|
+
|
|
68
|
+
1. Use a descriptive kebab-case name (max 64 chars)
|
|
69
|
+
2. Write a precise description with WHAT + WHEN
|
|
70
|
+
3. Distill into actionable instructions
|
|
71
|
+
4. Add compact examples
|
|
72
|
+
5. Include scripts/assets only when needed
|
|
73
|
+
|
|
74
|
+
For updates:
|
|
75
|
+
|
|
76
|
+
1. Read existing `SKILL.md`
|
|
77
|
+
2. Integrate into the best section
|
|
78
|
+
3. Avoid duplication
|
|
79
|
+
4. Keep structure and voice consistent
|
|
80
|
+
|
|
81
|
+
### Phase 5: Distill for Reuse
|
|
82
|
+
|
|
83
|
+
Guidelines:
|
|
84
|
+
|
|
85
|
+
1. Lead with general principles
|
|
86
|
+
2. Generalize from specific files/functions
|
|
87
|
+
3. Explain why the pattern exists
|
|
88
|
+
4. Include only context the agent would not infer
|
|
89
|
+
5. Keep content concise and maintainable (`SKILL.md` under 500 lines)
|
|
90
|
+
|
|
91
|
+
Do not:
|
|
92
|
+
|
|
93
|
+
- Store conversation artifacts
|
|
94
|
+
- Overfit to single-file fixes
|
|
95
|
+
- Repeat obvious model knowledge
|
|
96
|
+
- Include excessive implementation trivia
|
|
97
|
+
|
|
98
|
+
### Phase 6: Write and Verify
|
|
99
|
+
|
|
100
|
+
If consolidating:
|
|
101
|
+
|
|
102
|
+
1. Merge into the chosen home skill
|
|
103
|
+
2. Keep sections clear and non-overlapping
|
|
104
|
+
3. Remove obsolete duplicate skills
|
|
105
|
+
4. Update name/description if scope broadened
|
|
106
|
+
|
|
107
|
+
If creating/updating:
|
|
108
|
+
|
|
109
|
+
1. Write `SKILL.md`
|
|
110
|
+
2. Verify trigger description accuracy
|
|
111
|
+
3. Verify no duplicate scope across skills
|
|
112
|
+
4. Confirm captured content with the user
|
|
113
|
+
|
|
114
|
+
## Consolidation Rules
|
|
115
|
+
|
|
116
|
+
- Prefer fewer, broader, discoverable skills over many narrow duplicates.
|
|
117
|
+
- Group frequently co-used patterns into one skill with clear sections.
|
|
118
|
+
- If overlap is discovered after creation, consolidate immediately.
|
|
119
|
+
|
|
120
|
+
## Edge Cases
|
|
121
|
+
|
|
122
|
+
- Multiple unrelated learnings: split into separate skills.
|
|
123
|
+
- Tiny single-rule capture: recommend a rule file instead of a skill.
|
|
124
|
+
- Major rewrite needed: ask whether to restructure or supersede.
|
|
125
|
+
- User asks to evolve standards safely: keep changes in project overlay unless explicit promotion is requested.
|
|
@@ -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
|
@@ -26,13 +26,10 @@ This `.agents` workspace is portable and command-first.
|
|
|
26
26
|
|
|
27
27
|
1. `/workflow:brainstorm` -> clarify what to build
|
|
28
28
|
2. `/workflow:plan` -> define how to build it
|
|
29
|
-
3. `/workflow:
|
|
30
|
-
4. `/workflow:
|
|
31
|
-
5. `/workflow:
|
|
32
|
-
|
|
33
|
-
Supporting command:
|
|
34
|
-
|
|
35
|
-
- `/workflow:triage` -> approve and prioritize pending todos
|
|
29
|
+
3. `/workflow:triage` -> approve and prioritize the todo queue before execution
|
|
30
|
+
4. `/workflow:work` -> implement
|
|
31
|
+
5. `/workflow:review` -> validate quality
|
|
32
|
+
6. `/workflow:compound` -> capture durable learnings
|
|
36
33
|
|
|
37
34
|
Continuous improvement:
|
|
38
35
|
|
|
@@ -56,6 +53,7 @@ Use the canonical command names (`/workflow:plan`, `/workflow:work`, `/workflow:
|
|
|
56
53
|
- **Solution scope contract is mandatory in every plan.** Plans must declare `solution_scope` (`partial_fix|full_remediation|migration`) plus explicit completion expectation and non-goals so `/workflow:work` can enforce intent.
|
|
57
54
|
- **SpecFlow is a validation gate, not a rewrite engine.** High fidelity required; Medium recommended; Low optional. Output must translate into acceptance criteria/edge cases, not new scope.
|
|
58
55
|
- **Isolation preflight is a hard gate.** `/workflow:work` must complete and record worktree/isolation preflight before any implementation commands. `/workflow:review` must do the same for non-current PR/branch targets before analysis.
|
|
56
|
+
- **Triage before execution is mandatory.** `/workflow:work` must not execute todos until a `/workflow:triage` pass has prioritized the queue and validated dependencies/ready state for the current plan.
|
|
59
57
|
- **Spike governance is explicit and ordered.** Risky plans must evaluate spike need, spike candidates must declare initial priority/dependencies/unblocks/timebox/deliverable, triage confirms those assumptions, and `/workflow:work` executes blocking spikes before dependent build todos.
|
|
60
58
|
- **Agentic access/testability is mandatory in planning.** Every plan must include an executable access + validation contract so work/review can run deterministically.
|
|
61
59
|
- **Todo completion requires evidence.** A todo may move to `complete` only after success criteria evidence and quality gate evidence are recorded in Work Log.
|
|
@@ -157,7 +155,7 @@ worktree_bootstrap_notes:
|
|
|
157
155
|
## Implemented Components (Current Scope)
|
|
158
156
|
|
|
159
157
|
- 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`
|
|
158
|
+
- Skills: `brainstorming`, `document-review`, `technical-review`, `compound-docs` (alias: `compound_doc`), `capture-skill`, `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
159
|
- Agents:
|
|
162
160
|
- `repo-research-analyst`
|
|
163
161
|
- `learnings-researcher`
|
|
@@ -213,12 +211,14 @@ Maintenance:
|
|
|
213
211
|
| `document-review` | You need to review a document/spec and extract issues, gaps, and concrete next actions. |
|
|
214
212
|
| `technical-review` | A plan or feature approach has passed document review and must be checked for technical correctness before build. |
|
|
215
213
|
| `compound-docs` (alias: `compound_doc`) | A durable learning (solved problem or implementation insight) should be captured as institutional knowledge. |
|
|
214
|
+
| `capture-skill` | You want to capture conversation learnings as a reusable skill, while defaulting to project-overlay refinement and avoiding implicit edits to repo-provided base skills. |
|
|
216
215
|
| `file-todos` | You need a file-backed todo workflow for iterative multi-step changes. |
|
|
217
216
|
| `agent-browser` | You need to inspect available agents/skills and route deterministically. |
|
|
218
217
|
| `git-worktree` | You need isolated parallel work (review/feature) using git worktrees. |
|
|
219
218
|
| `process-metrics` | You want to log and assess session performance and process improvements. |
|
|
220
219
|
| `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
220
|
| `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). |
|
|
221
|
+
| `standards` | You need Altai coding standards for implementation and refactoring, including domain entity patterns, XState conventions, type usage, and feature code organization. |
|
|
222
222
|
|
|
223
223
|
### Reference standards (guardrails)
|
|
224
224
|
|