@openora/create 0.4.1-canary.31 → 0.4.1-canary.310

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.
Files changed (91) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/generated/core-version.d.ts +1 -1
  3. package/dist/generated/core-version.d.ts.map +1 -1
  4. package/dist/generated/core-version.js +1 -1
  5. package/dist/generated/core-version.js.map +1 -1
  6. package/dist/index.js +47 -92
  7. package/dist/index.js.map +1 -1
  8. package/dist/render.d.ts +15 -0
  9. package/dist/render.d.ts.map +1 -0
  10. package/dist/render.js +125 -0
  11. package/dist/render.js.map +1 -0
  12. package/package.json +7 -5
  13. package/template/README.md.tpl +12 -7
  14. package/template/__dot__claude/settings.json.tpl +22 -12
  15. package/template/__dot__env.example +10 -0
  16. package/template/__dot__gitignore +6 -1
  17. package/template/__dot__rulesync/commands/adr.md +32 -0
  18. package/template/__dot__rulesync/commands/check.md +11 -10
  19. package/template/__dot__rulesync/commands/doctor.md.tpl +17 -0
  20. package/template/__dot__rulesync/commands/scaffold-module.md +3 -2
  21. package/template/__dot__rulesync/commands/scaffold-plugin.md +19 -3
  22. package/template/__dot__rulesync/commands/scaffold-route.md +1 -0
  23. package/template/__dot__rulesync/commands/start.md +2 -2
  24. package/template/__dot__rulesync/hooks/_shared.mjs +5 -0
  25. package/template/__dot__rulesync/hooks/guard-core.mjs.tpl +75 -26
  26. package/template/__dot__rulesync/hooks/guard-generated.mjs +12 -5
  27. package/template/__dot__rulesync/hooks/guard-mr.mjs +81 -0
  28. package/template/__dot__rulesync/hooks/guard-subagent.mjs +22 -6
  29. package/template/__dot__rulesync/hooks/post-edit.mjs.tpl +106 -0
  30. package/template/__dot__rulesync/hooks.json +27 -4
  31. package/template/__dot__rulesync/rules/conventions.md +114 -0
  32. package/template/__dot__rulesync/rules/db-conventions.md +6 -72
  33. package/template/__dot__rulesync/rules/e2e-conventions.md +52 -0
  34. package/template/__dot__rulesync/rules/frontend-conventions.md.tpl +35 -0
  35. package/template/__dot__rulesync/rules/oss-boundaries.md.tpl +78 -0
  36. package/template/__dot__rulesync/rules/overview.md +18 -73
  37. package/template/__dot__rulesync/skills/create-plugin/{SKILL.md → SKILL.md.tpl} +13 -24
  38. package/template/__dot__rulesync/skills/create-pr/SKILL.md.tpl +38 -0
  39. package/template/__dot__rulesync/skills/create-task/{SKILL.md → SKILL.md.tpl} +14 -22
  40. package/template/__dot__rulesync/skills/create-ui-module/SKILL.md.tpl +60 -0
  41. package/template/__dot__rulesync/skills/enhance-prompt/{SKILL.md → SKILL.md.tpl} +4 -4
  42. package/template/__dot__rulesync/skills/handoff/SKILL.md +95 -0
  43. package/template/__dot__rulesync/skills/review/SKILL.md.tpl +277 -0
  44. package/template/__dot__rulesync/skills/ship/SKILL.md.tpl +149 -0
  45. package/template/__dot__rulesync/skills/ship/handoff.md.tpl +48 -0
  46. package/template/__dot__rulesync/skills/ship/watch-pr.mjs +168 -0
  47. package/template/__dot__rulesync/subagents/builder.md.tpl +115 -0
  48. package/template/__dot__rulesync/subagents/compliance-reviewer.md +78 -0
  49. package/template/__dot__rulesync/subagents/debugger.md.tpl +86 -0
  50. package/template/__dot__rulesync/subagents/deployer.md.tpl +54 -0
  51. package/template/__dot__rulesync/subagents/expert.md +28 -34
  52. package/template/__dot__rulesync/subagents/planner.md.tpl +39 -0
  53. package/template/__dot__rulesync/subagents/quality-reviewer.md.tpl +112 -0
  54. package/template/__dot__rulesync/subagents/security-reviewer.md.tpl +85 -0
  55. package/template/__dot__rulesync/subagents/tester.md.tpl +88 -0
  56. package/template/__dot__rulesync/sync.json.tpl +26 -0
  57. package/template/apps/api/package.json.tpl +1 -2
  58. package/template/apps/api/src/extensions.config.ts.tpl +12 -0
  59. package/template/apps/api/src/main.ts.tpl +1 -4
  60. package/template/apps/api/src/seed.ts.tpl +8 -1
  61. package/template/docker-compose.yml +13 -0
  62. package/template/docs/agents/cross-repo.md.tpl +37 -0
  63. package/template/docs/agents/forge.md.tpl +54 -0
  64. package/template/docs/agents/issue-tracker.md.tpl +42 -0
  65. package/template/docs/standards/comments.md +16 -0
  66. package/template/docs/standards/database.md +77 -0
  67. package/template/docs/standards/enforcement.md +12 -0
  68. package/template/docs/standards/errors.md +26 -0
  69. package/template/docs/standards/frontend.md +60 -0
  70. package/template/docs/standards/functions.md +38 -0
  71. package/template/docs/standards/git-delivery.md +20 -0
  72. package/template/docs/standards/module-structure.md +37 -0
  73. package/template/docs/standards/testing.md +72 -0
  74. package/template/docs/standards/types.md +31 -0
  75. package/template/package.json.tpl +11 -5
  76. package/template/rulesync.jsonc +1 -1
  77. package/template/tools/oss-worktree.mjs.tpl +105 -0
  78. package/template/tools/review-precheck.mjs +531 -0
  79. package/template/tools/sync-agents.mjs +257 -0
  80. package/template/turbo/generators/config.ts +1 -1
  81. package/template/turbo.json +14 -2
  82. package/template/__dot__rulesync/hooks/post-edit.mjs +0 -57
  83. package/template/__dot__rulesync/skills/add-feature/SKILL.md +0 -113
  84. package/template/__dot__rulesync/skills/add-feature/handoff.md +0 -58
  85. package/template/__dot__rulesync/skills/code-review/SKILL.md +0 -111
  86. package/template/__dot__rulesync/skills/create-pr/SKILL.md +0 -55
  87. package/template/__dot__rulesync/subagents/builder.md +0 -93
  88. package/template/__dot__rulesync/subagents/debugger.md +0 -83
  89. package/template/__dot__rulesync/subagents/deployer.md +0 -66
  90. package/template/__dot__rulesync/subagents/qa.md +0 -88
  91. package/template/apps/api/src/migrate.ts.tpl +0 -46
@@ -1,44 +1,93 @@
1
1
  #!/usr/bin/env node
2
- // PreToolUse guard (Claude / Copilot CLI / Codex CLI / Gemini CLI).
3
- // Enforces the HARD RULE: never modify OSS core. Blocks shell write-primitives
4
- // and direct file edits that target the linked OSS checkout or node_modules.
5
- // Reads/blocks are allowed; only writes into protected paths are denied.
2
+ // PreToolUse guard (Claude / Copilot CLI / Codex CLI / Gemini CLI - rulesync renders the same
3
+ // script for every hook target, so the rule below holds identically in each tool).
4
+ // Enforces the HARD RULE: the linked OSS checkout and node_modules are read-only. The one
5
+ // exception is a git worktree under <oss>/.worktrees/ - the sanctioned place to change OSS
6
+ // code from this repo (oss-boundaries rule, `pnpm oss:worktree`). Reads are always allowed.
6
7
 
8
+ import { readFileSync, realpathSync } from 'node:fs';
9
+ import { basename, dirname, join, resolve, sep } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
7
11
  import { extractCommand, extractFilePath, readPayload, deny } from './_shared.mjs';
8
12
 
9
13
  const payload = readPayload();
10
14
 
11
- // The linked OSS core checkout (baked in at generation time) plus any node_modules.
12
- const ossPath = String.raw`{{ossFromRoot}}`;
13
- const escapedOss = ossPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
- const PROTECTED = `(?:${escapedOss}|node_modules\\b)`;
15
+ // Paths resolve from the repo root (this file lives in <root>/.rulesync/hooks/), so an
16
+ // absolute path - what most tools send - is caught as well as the relative form.
17
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
18
+ const OSS_RELATIVE = String.raw`{{ossFromRoot}}`;
19
+ // This repo is often checked out as a git worktree, whose `.git` is a file pointing at
20
+ // <main>/.git/worktrees/<name>. The OSS checkout is a sibling of the MAIN checkout, so without
21
+ // this the guard would resolve it inside the worktree, match nothing, and let core writes pass.
22
+ const mainCheckoutOf = (dir) => {
23
+ try {
24
+ const pointer = readFileSync(join(dir, '.git'), 'utf8');
25
+ const gitdir = pointer.match(/^gitdir:\s*(.*[/\\]worktrees[/\\][^/\\]+?)\s*$/m)?.[1];
26
+ return gitdir ? resolve(dir, gitdir, '..', '..', '..') : dir;
27
+ } catch {
28
+ return dir; // `.git` is a directory (the main checkout) or missing
29
+ }
30
+ };
31
+ const OSS = resolve(mainCheckoutOf(ROOT), OSS_RELATIVE);
32
+
33
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
+ const OSS_PATH = `(?:${escape(OSS)}|${escape(OSS_RELATIVE)})`;
35
+ const PROTECTED = `(?:${OSS_PATH}|node_modules\\b)`;
36
+ // A path into a worktree with no `..` hop and no node_modules segment is sanctioned; it is
37
+ // masked out before the write checks run, so the rest of the command is still judged.
38
+ const WORKTREE_TOKEN = new RegExp(
39
+ String.raw`${OSS_PATH}/\.worktrees/(?![^\s'"|;&]*(?:\.\.|node_modules))[^\s'"|;&]*`,
40
+ 'g',
41
+ );
42
+ // cp, install, and rsync write only to their last argument, so copying out of core stays a read.
43
+ const LAST_ARG = String.raw`\s['"]?[^'"\s|;&]*${PROTECTED}[^'"\s|;&]*['"]?(?:\s+\d?>\S*)*\s*(?:[|;&]|$)`;
44
+ const GIT_WRITES = 'apply|am|checkout|restore|reset|clean|stash|merge|rebase|pull|switch|cherry-pick|revert|rm|mv';
45
+
46
+ const HOW_TO_CHANGE_OSS =
47
+ 'To change OSS code, run `pnpm oss:worktree <branch>` and edit inside ' +
48
+ `${OSS_RELATIVE}/.worktrees/ (rule: oss-boundaries). Otherwise extend from the OUTSIDE ` +
49
+ '(overlay plugin, adapter rebinding, UI plugin, config).';
15
50
 
16
- // 1) Shell command writing into a protected path (sed -i, redirect, tee, rm, ...).
17
- const command = extractCommand(payload);
51
+ // 1) Shell command writing into a protected path (sed -i, redirect, tee, cp, rm, git, ...).
52
+ const command = extractCommand(payload).replace(WORKTREE_TOKEN, '<oss-worktree>');
18
53
  if (command) {
19
54
  const writeToCore = [
20
- [new RegExp(String.raw`\b(?:sed|perl)\b[^|;&]*\s-\w*i\w*\b[^|;&]*(?:${PROTECTED})`), 'in-place edit (sed/perl -i)'],
21
- [new RegExp(String.raw`(?:>>?|>\|)\s*['"]?[^'"\s|;&]*(?:${PROTECTED})`), 'shell redirection'],
22
- [new RegExp(String.raw`\btee\b\s+(?:-a\s+)?['"]?[^'"\s|;&]*(?:${PROTECTED})`), 'tee'],
23
- [new RegExp(String.raw`\b(?:rm|truncate|dd|chmod|chown|unlink|shred|mv)\b[^|;&]*(?:${PROTECTED})`), 'destructive file op'],
55
+ [new RegExp(String.raw`\b(?:sed|perl)\b[^|;&]*\s-\w*i\w*\b[^|;&]*${PROTECTED}`), 'in-place edit (sed/perl -i)'],
56
+ [new RegExp(String.raw`(?:>>?|>\|)\s*['"]?[^'"\s|;&]*${PROTECTED}`), 'shell redirection'],
57
+ [new RegExp(String.raw`\btee\b\s+(?:-a\s+)?['"]?[^'"\s|;&]*${PROTECTED}`), 'tee'],
58
+ [new RegExp(String.raw`(?<![\w-])(?:cp|install|rsync)\b[^|;&]*${LAST_ARG}`), 'copy'],
59
+ [new RegExp(String.raw`(?<![\w-])(?:rm|truncate|dd|chmod|chown|unlink|shred|mv|ln|patch)\b[^|;&]*${PROTECTED}`), 'destructive file op'],
60
+ [new RegExp(String.raw`\bgit\b[^|;&]*${PROTECTED}[^|;&]*\s(?:${GIT_WRITES})\b`), 'git command'],
24
61
  ];
25
- const hit = writeToCore.find(([re]) => re.test(command));
62
+ const hit = writeToCore.find(([pattern]) => pattern.test(command));
26
63
  if (hit) {
27
- deny(
28
- `Blocked: this ${hit[1]} writes into OSS core / node_modules, which is read-only here. ` +
29
- 'Extend the platform from the OUTSIDE (overlay plugin, adapter rebinding, UI plugin, config). ' +
30
- 'If it can only be fixed in core, STOP and report it as an upstream OSS issue.',
31
- );
64
+ deny(`Blocked: this ${hit[1]} writes into the OSS checkout or node_modules, which are read-only here. ${HOW_TO_CHANGE_OSS}`);
32
65
  }
33
66
  }
34
67
 
35
- // 2) Direct file edit/write whose target is inside a protected path.
68
+ // The path on disk, symlinks followed, so a link in this repo that points into the checkout
69
+ // does not pass as a local path. A file not yet created resolves through its nearest existing
70
+ // ancestor.
71
+ const physical = (path) => {
72
+ try {
73
+ return realpathSync(path);
74
+ } catch {
75
+ const parent = dirname(path);
76
+ return parent === path ? path : join(physical(parent), basename(path));
77
+ }
78
+ };
79
+
80
+ // 2) Direct file edit/write whose target resolves inside a protected path.
36
81
  const filePath = extractFilePath(payload);
37
- if (filePath && new RegExp(PROTECTED).test(filePath)) {
38
- deny(
39
- `Blocked: ${filePath} is inside OSS core / node_modules, which is read-only here. ` +
40
- 'Extend from the OUTSIDE (overlay, adapter, UI plugin, config), or report an upstream OSS issue.',
41
- );
82
+ if (filePath) {
83
+ const written = resolve(payload.cwd ?? process.cwd(), filePath);
84
+ const target = physical(written);
85
+ const oss = physical(OSS);
86
+ const isInside = (dir) => target.startsWith(dir + sep);
87
+ const inNodeModules = [written, target].some((path) => /(?:^|[\\/])node_modules(?:[\\/]|$)/.test(path));
88
+ if (inNodeModules || (isInside(oss) && !isInside(join(oss, '.worktrees')))) {
89
+ deny(`Blocked: ${filePath} is inside the OSS checkout or node_modules, which are read-only here. ${HOW_TO_CHANGE_OSS}`);
90
+ }
42
91
  }
43
92
 
44
93
  process.exit(0);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // PreToolUse guard (Claude / Copilot CLI / Codex CLI).
3
- // Blocks edits to files generated by rulesync (`pnpm sync:agents`). The single
2
+ // PreToolUse guard (Claude / Copilot CLI / Codex CLI / Gemini CLI).
3
+ // Blocks edits to files generated by rulesync (`pnpm gen:agents`). The single
4
4
  // source of truth is .rulesync/; editing a generated mirror is lost on the next
5
5
  // sync. Fail-open: only a positive match denies.
6
6
 
@@ -9,25 +9,32 @@ import { relative, isAbsolute } from 'node:path';
9
9
 
10
10
  const payload = readPayload();
11
11
  const filePath = extractFilePath(payload);
12
- if (!filePath) process.exit(0);
12
+ if (!filePath) {
13
+ process.exit(0);
14
+ }
13
15
 
16
+ // Repo-relative (hooks run with cwd = repo root) - name-agnostic.
14
17
  const rel = isAbsolute(filePath) ? relative(process.cwd(), filePath) : filePath;
15
18
 
19
+ // Everything rulesync generates from .rulesync/.
16
20
  const generated = [
17
21
  /^AGENTS\.md$/,
18
22
  /^CLAUDE\.md$/,
23
+ /^GEMINI\.md$/,
19
24
  /^\.github\/copilot-instructions\.md$/,
20
25
  /^\.mcp\.json$/,
21
26
  /^\.vscode\/mcp\.json$/,
22
27
  /^\.codex\/config\.toml$/,
28
+ /^\.gemini\/settings\.json$/,
23
29
  /^\.claude\/(agents|commands)\//,
24
30
  /^\.github\/(agents|prompts)\//,
31
+ /^\.gemini\/(agents|commands)\//,
25
32
  ];
26
33
 
27
34
  if (generated.some((re) => re.test(rel))) {
28
35
  deny(
29
- `Blocked: ${rel} is generated by rulesync (\`pnpm sync:agents\`). ` +
30
- 'Edit the source under .rulesync/ instead (rules/, subagents/, commands/, mcp.json), then run `pnpm sync:agents`.',
36
+ `Blocked: ${rel} is generated by rulesync (\`pnpm gen:agents\`). ` +
37
+ 'Edit the source under .rulesync/ instead (rules/, subagents/, commands/, mcp.json), then run `pnpm gen:agents`.',
31
38
  );
32
39
  }
33
40
 
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: no merge/pull request until `pnpm verify` passed on the commit being sent.
3
+ // `pnpm verify` ends with `node .rulesync/hooks/guard-mr.mjs --stamp`, which records HEAD in
4
+ // node_modules/.cache/verify-head when the tree is clean; a `--stamp` outside the verify script is
5
+ // refused. Opening an MR/PR (CLI, push option, REST or GraphQL) is denied unless that record matches
6
+ // the current HEAD of a clean tree. Git errors fail open, like every other guard.
7
+
8
+ import { execFileSync } from 'node:child_process';
9
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { deny, extractCommand, readPayload } from './_shared.mjs';
12
+
13
+ const OPENS_REVIEW =
14
+ /\b(?:glab\s+mr\s+create|gh\s+pr\s+create|createPullRequest|mergeRequestCreate)\b|merge_request\.create/;
15
+ const API_CALL = /\b(?:gh|glab)\s+api\b/;
16
+ const REVIEW_COLLECTION = /\/(?:pulls|merge_requests)(?=["'\s?]|$)/;
17
+ // `gh api` and `glab api` default to POST once a field or body is passed.
18
+ const WRITES = /\s(?:(?:-X\s*|--method[=\s]+)POST\b|(?:-f|-F|--field|--raw-field|--input)\b)/i;
19
+ const READS = /\s(?:-X\s*|--method[=\s]+)GET\b/i;
20
+
21
+ const opensReview = (command) =>
22
+ OPENS_REVIEW.test(command) ||
23
+ (API_CALL.test(command) &&
24
+ REVIEW_COLLECTION.test(command) &&
25
+ WRITES.test(command) &&
26
+ !READS.test(command));
27
+
28
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
29
+ const stampFile = (root) => join(root, 'node_modules', '.cache', 'verify-head');
30
+
31
+ const checkout = (cwd) => {
32
+ try {
33
+ const root = git(cwd, 'rev-parse', '--show-toplevel');
34
+ return {
35
+ root,
36
+ head: git(root, 'rev-parse', 'HEAD'),
37
+ isClean: git(root, 'status', '--porcelain') === '',
38
+ };
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ };
43
+
44
+ if (process.argv.includes('--stamp')) {
45
+ if (process.env.npm_lifecycle_event !== 'verify') {
46
+ process.stderr.write('--stamp is recorded by `pnpm verify` only: run `pnpm verify`.\n');
47
+ process.exit(1);
48
+ }
49
+ const state = checkout(process.cwd());
50
+ if (state?.isClean) {
51
+ mkdirSync(dirname(stampFile(state.root)), { recursive: true });
52
+ writeFileSync(stampFile(state.root), state.head);
53
+ } else {
54
+ process.stderr.write(
55
+ 'verify passed on uncommitted changes: commit, then rerun `pnpm verify` before opening an MR/PR.\n',
56
+ );
57
+ }
58
+ process.exit(0);
59
+ }
60
+
61
+ const payload = readPayload();
62
+ if (!opensReview(extractCommand(payload))) {
63
+ process.exit(0);
64
+ }
65
+ const state = checkout(payload.cwd ?? process.cwd());
66
+ if (!state) {
67
+ process.exit(0);
68
+ }
69
+ const readStamp = () => {
70
+ try {
71
+ return readFileSync(stampFile(state.root), 'utf8').trim();
72
+ } catch {
73
+ return '';
74
+ }
75
+ };
76
+ if (!state.isClean || readStamp() !== state.head) {
77
+ deny(
78
+ 'Blocked: run `pnpm verify` on a clean, committed HEAD before opening a merge/pull request. It records the verified commit; this check compares it with HEAD.',
79
+ );
80
+ }
81
+ process.exit(0);
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // PreToolUse(Task) guard: keep the orchestrator from spawning a GENERIC subagent
3
3
  // (general-purpose / claude) for work a roster agent is purpose-built for. The
4
- // Agent roster in AGENTS.md is prose the model sometimes skips out of habit; this
5
- // is the deterministic backstop.
4
+ // Agent roster in the workflow rule is prose the model sometimes skips out of
5
+ // habit; this is the deterministic backstop.
6
6
  //
7
7
  // Conservative by design: it acts ONLY when (a) the chosen subagent is generic AND
8
8
  // (b) the task text contains a HIGH-SIGNAL phrase that maps unambiguously to one
@@ -16,14 +16,16 @@ const ti = payload.tool_input ?? {};
16
16
  const sub = String(ti.subagent_type ?? '').toLowerCase();
17
17
 
18
18
  const GENERIC = new Set(['general-purpose', 'claude', '']);
19
- if (!GENERIC.has(sub)) process.exit(0);
19
+ if (!GENERIC.has(sub)) {
20
+ process.exit(0);
21
+ }
20
22
 
21
23
  const text = `${ti.description ?? ''}\n${ti.prompt ?? ''}`.toLowerCase();
22
24
 
23
25
  // High-signal phrase -> the roster agent that owns it. Keep each pattern SPECIFIC;
24
26
  // err toward missing a case over false-blocking a generic task.
25
27
  const ROUTES = [
26
- { agent: 'qa', re: /\b(playwright|e2e test|end-to-end test|e2e coverage)\b/ },
28
+ { agent: 'tester', re: /\b(playwright|e2e test|end-to-end test|e2e coverage)\b/ },
27
29
  {
28
30
  agent: 'debugger',
29
31
  re: /\b(build (failure|error)|turbopack|tsc error|module resolution|runtime error|stack trace)\b/,
@@ -40,14 +42,28 @@ const ROUTES = [
40
42
  agent: 'deployer',
41
43
  re: /\b(dockerfile|containerize|deploy pipeline|deploy to (ecs|kubernetes|fly|railway|render)|ci\/cd deploy|helm chart)\b/,
42
44
  },
45
+ {
46
+ agent: 'compliance-reviewer',
47
+ re: /\b(compliance|responsible gambling|ledger|audit trail) review\b/,
48
+ },
49
+ {
50
+ agent: 'quality-reviewer',
51
+ re: /\b(quality review|code quality|over-engineer(ed|ing)?|simplification review|duplication review)\b|\breview (the )?(mr|pr|diff|changed files)\b/,
52
+ },
53
+ {
54
+ agent: 'security-reviewer',
55
+ re: /\bsecurity (review|audit)\b|\baudit\b[^.]*\bvulnerabilit|\b(authz|owasp)\b/,
56
+ },
43
57
  ];
44
58
 
45
59
  const hit = ROUTES.find((r) => r.re.test(text));
46
- if (!hit) process.exit(0);
60
+ if (!hit) {
61
+ process.exit(0);
62
+ }
47
63
 
48
64
  process.stderr.write(
49
65
  `Use the \`${hit.agent}\` subagent for this task, not \`${sub || 'general-purpose'}\`. ` +
50
- `It is pre-scoped for this work (tools + model + brief) - see the Agent roster in AGENTS.md. ` +
66
+ `It is pre-scoped for this work (tools + model + brief) - see the Agents list in the workflow rule. ` +
51
67
  `Re-issue the Task with subagent_type: "${hit.agent}". ` +
52
68
  `If it genuinely does not fit ${hit.agent}, rephrase the description to say why.\n`,
53
69
  );
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse hook (Claude / Copilot CLI / Codex CLI / Gemini CLI).
3
+ // Lint-fixes the edited file, then typechecks the app that owns it and reports
4
+ // (exit 2) only if the error is in the file just edited. Output is capped so a
5
+ // failure can't balloon the model context. Fail-open on anything unexpected.
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join, isAbsolute, relative } from 'node:path';
10
+ import { extractFilePath, readPayload } from './_shared.mjs';
11
+
12
+ const CAP_LINES = 40;
13
+ const CAP_CHARS = 2000;
14
+
15
+ const filePath = extractFilePath(readPayload());
16
+ if (!filePath) {
17
+ process.exit(0);
18
+ }
19
+ if (!/\.(ts|tsx)$/.test(filePath) || filePath.endsWith('.d.ts')) {
20
+ process.exit(0);
21
+ }
22
+ if (filePath.includes('/templates/') || filePath.includes('/generated/')) {
23
+ process.exit(0);
24
+ }
25
+
26
+ /**
27
+ * node_modules/.bin/<name> when it exists, else the bare name off PATH. `pnpm exec`
28
+ * costs ~2.2s of package-manager startup per call against ~0.12s for the binary, and
29
+ * this hook runs on every edit.
30
+ */
31
+ function bin(name) {
32
+ const local = join(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), 'node_modules/.bin', name);
33
+ return existsSync(local) ? local : name;
34
+ }
35
+
36
+ // Lint-fix (best effort - never block on the linter).
37
+ try {
38
+ execFileSync(bin('oxlint'), ['--fix', filePath], { stdio: 'pipe' });
39
+ } catch {
40
+ /* oxlint unavailable or errored - fall through to the re-lint / typecheck */
41
+ }
42
+
43
+ // Re-lint WITHOUT --fix to surface UNFIXABLE errors the agent must resolve by
44
+ // hand - import/no-cycle and the no-restricted-imports boundary rules (deep @openora
45
+ // or {{scope}}/ui imports, app-to-app imports). oxlint exits non-zero on errors
46
+ // only (warnings don't fail), so this blocks on a real boundary/cycle violation
47
+ // in the edited file and feeds the message back. Fail-open on anything else.
48
+ try {
49
+ execFileSync(bin('oxlint'), [filePath], { stdio: 'pipe' });
50
+ } catch (e) {
51
+ const out = (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '');
52
+ if (/\berror\b/.test(out)) {
53
+ process.stderr.write(
54
+ `Boundary/lint error in ${filePath} - fix before continuing:\n${cap(out)}`,
55
+ );
56
+ process.exit(2);
57
+ }
58
+ }
59
+
60
+ // Resolve the owning workspace app (apps/<x> or packages/<x>).
61
+ function packageNameFor(fp) {
62
+ const m = fp.match(/(.*?\/(?:apps|packages)\/[^/]+)\//);
63
+ if (!m) {
64
+ return null;
65
+ }
66
+ try {
67
+ const pkg = JSON.parse(readFileSync(join(m[1], 'package.json'), 'utf8'));
68
+ if (!pkg.name || !pkg.scripts?.['check:types']) {
69
+ return null;
70
+ }
71
+ return pkg.name;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ const abs = isAbsolute(filePath) ? filePath : join(process.cwd(), filePath);
78
+ const owner = packageNameFor(abs);
79
+ if (!owner) {
80
+ process.exit(0);
81
+ }
82
+
83
+ function cap(text) {
84
+ const out = text.split('\n').slice(0, CAP_LINES).join('\n').slice(0, CAP_CHARS);
85
+ return out.length < text.length ? `${out}\n... (truncated)` : out;
86
+ }
87
+
88
+ try {
89
+ execFileSync(
90
+ bin('turbo'),
91
+ ['run', 'check:types', '--filter', owner, '--output-logs=errors-only', '--ui=stream'],
92
+ { stdio: 'pipe' },
93
+ );
94
+ process.exit(0);
95
+ } catch (e) {
96
+ const output = (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '');
97
+ const basename =
98
+ (isAbsolute(filePath) ? relative(process.cwd(), filePath) : filePath).split('/').pop() ?? '';
99
+ if (basename && output.includes(basename)) {
100
+ process.stderr.write(
101
+ `Typecheck failed for ${owner} after editing ${basename}:\n${cap(output)}`,
102
+ );
103
+ process.exit(2);
104
+ }
105
+ process.exit(0);
106
+ }
@@ -2,10 +2,33 @@
2
2
  "version": 1,
3
3
  "hooks": {
4
4
  "preToolUse": [
5
- { "type": "command", "command": "node .rulesync/hooks/guard-core.mjs" },
6
- { "type": "command", "command": "node .rulesync/hooks/guard-generated.mjs" },
7
- { "type": "command", "matcher": "Task", "command": "node .rulesync/hooks/guard-subagent.mjs" }
5
+ {
6
+ "type": "command",
7
+ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
8
+ "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/guard-core.mjs\""
9
+ },
10
+ {
11
+ "type": "command",
12
+ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
13
+ "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/guard-generated.mjs\""
14
+ },
15
+ {
16
+ "type": "command",
17
+ "matcher": "Bash",
18
+ "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/guard-mr.mjs\""
19
+ },
20
+ {
21
+ "type": "command",
22
+ "matcher": "Task|Agent",
23
+ "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/guard-subagent.mjs\""
24
+ }
8
25
  ],
9
- "postToolUse": [{ "type": "command", "command": "node .rulesync/hooks/post-edit.mjs" }]
26
+ "postToolUse": [
27
+ {
28
+ "type": "command",
29
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
30
+ "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/post-edit.mjs\""
31
+ }
32
+ ]
10
33
  }
11
34
  }
@@ -0,0 +1,114 @@
1
+ ---
2
+ root: false
3
+ targets:
4
+ - '*'
5
+ globs:
6
+ - 'apps/**'
7
+ - 'packages/**'
8
+ - 'tools/**'
9
+ - '*.config.*'
10
+ - 'package.json'
11
+ description: Engineering code conventions (TS, DB, frontend, testing, git) - the always-on core, with a routing table to full detail in docs/standards/.
12
+ ---
13
+
14
+ # Engineering conventions
15
+
16
+ The always-on core of the code standard: what you must obey while typing. Detail, examples, and
17
+ rationale live in `docs/standards/` - read the one file that matches the change instead of
18
+ carrying all of it. The enforced import graph lives in `oss-boundaries`; Playwright rules in
19
+ `e2e-conventions`; the always-on UI and SQL cores in `frontend-conventions` and `db-conventions`.
20
+
21
+ | Change you are making | Read first |
22
+ | ------------------------------------- | ------------------------------------ |
23
+ | schema, type, enum-like value set | `docs/standards/types.md` |
24
+ | function, service method, constructor | `docs/standards/functions.md` |
25
+ | new package, overlay, module layout | `docs/standards/module-structure.md` |
26
+ | a comment or JSDoc | `docs/standards/comments.md` |
27
+ | error class, catch, money path | `docs/standards/errors.md` |
28
+ | a test | `docs/standards/testing.md` |
29
+ | commit, PR | `docs/standards/git-delivery.md` |
30
+ | a failing gate, a new lint rule | `docs/standards/enforcement.md` |
31
+ | React/UI component, page, styling | `docs/standards/frontend.md` |
32
+ | SQL, Drizzle, migration, seed | `docs/standards/database.md` |
33
+
34
+ `docs/standards/frontend.md` doesn't apply to a headless/api-only repo - delete it (and its row
35
+ above) if this repo has no UI apps.
36
+
37
+ ## Philosophy
38
+
39
+ - Functional and declarative: pure functions, immutable data, composition. No stateful classes, no
40
+ imperative accumulation loops.
41
+ - Explicit over magic: no decorators, no auto-discovery, no reflection. Every wiring point is a
42
+ greppable typed call (`ctx.provide(TOKEN, factory)`).
43
+ - Self-documenting: clear names beat comments (`percentChange`, not `d` + a comment).
44
+ - YAGNI then DRY: don't build for imagined futures; abstract on the third occurrence, not the
45
+ first.
46
+ - Boring and consistent: match the surrounding code's idiom, naming, and density.
47
+
48
+ ## Never (lint-enforced unless noted)
49
+
50
+ - `any` outside tests, `!` non-null assertions, `as` casts to silence the compiler (`as const` is
51
+ fine). Two sanctioned casts: test doubles through one `mock<T>()` helper (never inline in a
52
+ test), and a third-party inference boundary a library gives no honest way to satisfy (one cast,
53
+ one-line `// Library boundary:` note).
54
+ - `interface`, TS `enum`, decorators, inheritance for reuse, default exports (exceptions:
55
+ `*.config.*`, `plugin.ts`, Next.js App Router files).
56
+ - Hand-written duplicates of an inferrable type, re-inferring an imported schema, re-typing
57
+ derived schema fields, ad-hoc/duplicated Zod schemas outside a module's `contract/`.
58
+ - Re-exporting types "to be nice" - import from where defined.
59
+ - Inline `fetch`/`axios` in module code - third-party access is a port + adapter bound at the
60
+ composition root.
61
+ - Deep (`../../`+) relative imports that leave your module/zone, imports of another module's
62
+ internals, import cycles, deep `dist/`/`src/` paths into another package.
63
+ - SQL anti-patterns (bare `timestamp()`, CamelCase identifiers, hand-edited migrations) - detail
64
+ in `docs/standards/database.md`.
65
+ - Silent catches - log with context and rethrow. `ORPCError.message` rendered to a player - UI copy
66
+ keys off `.code` + typed `.data` through `t()`.
67
+ - Comments, unless one states a fact the code cannot contain (an external system's behaviour, a
68
+ spec constraint); a reason goes in the commit or PR, never inline. Never in tests. Detail:
69
+ `docs/standards/comments.md`.
70
+ - An in-process test that mocks the database, a repository, or a sibling service - found one in
71
+ the diff, delete it and replace it with the API E2E; never add to it.
72
+
73
+ ## Always
74
+
75
+ - **Naming:** files `kebab-case.ts`, one concept per file, filename names the concept
76
+ (`wallet.service.ts`, never `helpers.ts`); types `PascalCase`; values/functions `camelCase`;
77
+ true global constants `SCREAMING_SNAKE_CASE`; Zod schemas `<Name>Schema` with inferred type
78
+ `<Name>`; booleans read as predicates (`isActive`, `canEdit`); money is a decimal string plus a
79
+ `currency` field alongside it, never `amountCents`; IO functions are verbs (`fetchInvoice`); names
80
+ carry units and intent (`delayMs`, `maxRetries`).
81
+ - **One source of truth per shape** - infer, never hand-write: `z.infer<typeof XSchema>`,
82
+ `typeof x.$inferSelect`. Full detail: `docs/standards/types.md`.
83
+ - **Literal config arrays/objects (option lists, key sets) use `as const`**, not an explicit union
84
+ type annotation - let TypeScript infer the literal types.
85
+ - **Schema-first at every boundary** (HTTP, config, env, events); validate once at the edge, trust
86
+ the type after.
87
+ - **Entity ids typed through their owning type** (`playerId: Player['id']`), never a bare
88
+ `string`.
89
+ - **Guard clauses first, main path last; more than 3 params -> one named object** (a leading
90
+ `tx` handle may stay positional). Always brace control statements, even one-liners (lint:
91
+ `curly`). Detail: `docs/standards/functions.md`.
92
+ - **Construct objects by spread + override**, never a hand-copied field list.
93
+ - **Side effects at the edges**; money paths are transactional AND idempotent (a DB guard inside
94
+ the transaction, not just an idempotency key). Detail: `docs/standards/errors.md`.
95
+ - **Typed, named error classes** from the shared factories, mapped to transport in the router's
96
+ `mapErrors`.
97
+ - **Pin exact dependency versions** (no `^`/`~`); add a dependency deliberately - std lib or a few
98
+ lines often beat a tree.
99
+ - **A feature or fix PR ships its own tests** - no separate test PR. It ships the change, the
100
+ manual verification pass that proves it (drive the running stack by hand, attach a short video
101
+ per acceptance criterion, or the request/response trace for an API-only change, to the PR
102
+ description), and the tests that pass exercised, listed in the description's "Tests" list.
103
+ Which tier each one belongs to, and how to run the manual pass: `docs/standards/testing.md`.
104
+ - **Green before review:** `/check` (typecheck + lint + unit tests) while iterating, `pnpm verify`
105
+ on the committed tree before the PR (the `guard-mr` hook refuses to open one until it passed on
106
+ HEAD). Conventional commits, lowercase subject (PR
107
+ title too - a squash merge turns it into the commit), one PR per concern. Never push without
108
+ explicit confirmation. The description carries what / why / acceptance criteria / bare ticket
109
+ key, the manual-verification evidence (a short video per acceptance criterion, before and after on
110
+ a fix) and the "Tests" list - but no CI checklist the pipeline already shows, no URLs,
111
+ hostnames, secrets, or PII. Detail: `docs/standards/git-delivery.md`.
112
+ - **Fix the import, never work around a lint or boundary violation.** Agent rules are generated
113
+ from `.rulesync/` via `pnpm gen:agents` - never hand-edit a generated file. Detail:
114
+ `docs/standards/enforcement.md`.
@@ -4,80 +4,14 @@ targets:
4
4
  - '*'
5
5
  globs:
6
6
  - 'apps/api/**'
7
- description: SQL / Drizzle conventions for tables an overlay or local add-on owns.
7
+ description: SQL / Drizzle conventions for tables an overlay or local add-on owns - the always-on core; full detail in docs/standards/database.md.
8
8
  ---
9
9
 
10
10
  # Database conventions (SQL / Drizzle)
11
11
 
12
- Applies to every table an overlay or local add-on owns (`apps/api/src/extensions/<name>/src/schema/`).
13
- Tables live in `@openora/*` core for platform domains - never edit those; these rules govern the
14
- tables you add. Boundary/import rules live in `oss-boundaries`; this file is SQL only.
12
+ Applies to every table an overlay or local add-on owns (`apps/api/src/extensions/<name>/src/schema/`). Platform-domain tables live in `@openora/*` core - never edit those. Identifiers, timestamps, keys, indexes, N+1, idempotency and migrations are specified in `docs/standards/database.md` - read it before touching a schema, query, migration, or seed. The rules below are the ones that file does not state.
15
13
 
16
- ## Identifiers - snake_case everywhere
17
-
18
- Every drizzle instance sets `casing: 'snake_case'`, so the SQL name derives from the camelCase key.
19
- Pass an explicit name only where casing can't derive it: table names, `pgEnum` types, index names.
20
-
21
- ```ts
22
- // good - key derives the column; const camelCase; explicit snake_case only for table + index
23
- export const playerNote = pgTable(
24
- 'player_note',
25
- {
26
- id: uuid().primaryKey().defaultRandom(),
27
- playerId: uuid().notNull(), // -> player_id
28
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
29
- },
30
- (t) => [index('player_note_player_id_idx').on(t.playerId)],
31
- );
32
-
33
- // bad - explicit/camelCase column names, PascalCase table
34
- pgTable('PlayerNote', { player_id: uuid('playerId') });
35
- ```
36
-
37
- ## Timestamps - always timestamptz
38
-
39
- ```ts
40
- createdAt: timestamp({ withTimezone: true }); // good - Postgres timestamptz, store UTC
41
- createdAt: timestamp(); // bad - naive, drops the zone
42
- ```
43
-
44
- Every datetime column (`createdAt`, `updatedAt`, `expiresAt`, any `*At`) carries the zone.
45
-
46
- ## Keys, references, indexes
47
-
48
- - UUID primary keys (`uuid().primaryKey().defaultRandom()`).
49
- - **No foreign keys across a module/overlay boundary** - store a plain ID string and resolve via the
50
- oRPC client or a schema subpath. FKs only within the same add-on.
51
- - `NOT NULL` by default; push defaults to the DB (`.notNull().default(...)`), not app code.
52
- - Index every column you filter or join on; name it `<table>_<cols>_idx`.
53
-
54
- ## Efficient operations
55
-
56
- ```ts
57
- // bad - N+1
58
- for (const id of ids) await db.select().from(wallet).where(eq(wallet.playerId, id));
59
- // good - one batched query
60
- await db.select().from(wallet).where(inArray(wallet.playerId, ids));
61
- ```
62
-
63
- - Select only the columns you use; don't `select(*)` wide rows to read one field.
64
- - **Build rows by spread + override, never hand-copy field-by-field.** When a row mostly mirrors a
65
- validated input, spread it and set only the server-computed fields - `db.insert(x).values({ ...input,
66
- id, createdAt })` - never re-list `field: input.field` per key (it silently drifts the moment a
67
- column is added). Keep fields explicit only for: an order-sensitive hash/signature payload; a source
68
- carrying columns the row must not receive (spread then omit them); or a null-vs-undefined boundary
69
- that won't coerce (`actorId: input.actorId ?? null`).
70
- - **Money / critical paths are transactional and idempotent** - a DB guard inside the transaction,
71
- not just an `idempotencyKey` (delivery is at-least-once).
72
-
73
- ```ts
74
- await db.transaction(async (t) => {
75
- if (await ledgerExists(t, idempotencyKey)) return; // guard, not just a key
76
- await insertLedger(t, { idempotencyKey, amountCents });
77
- });
78
- ```
79
-
80
- ## Migrations
81
-
82
- - Never hand-edit generated migrations. Change the `pgTable`, then `pnpm db:migrate`.
83
- - One migration per schema change; review the generated SQL before committing.
14
+ - Money: exact decimal, never float, never a scaled integer. Every money column is `decimal()` (Postgres `NUMERIC`) - never `real`/`float`, never an `integer` "cents" column. Pair it with a `currency` column; on the wire use `MoneyAmountSchema` (decimal string) + `currency`, never `z.number()`. Balance math runs in SQL, never JS float arithmetic.
15
+ - `pgEnum` derives from a values + schema + type triple declared once (never an inline value array): `X_STATUSES = [...] as const` -> `XStatusSchema = z.enum(X_STATUSES)` -> `pgEnum('x_status', X_STATUSES)`.
16
+ - Bound the fan-out: never `Promise.all(rows.map(fn))` when `rows` is a query result and `fn` calls out per row (a vendor adapter, another service) - it opens unbounded concurrent connections/requests and starves everything else at scale. Use `mapConcurrent(items, limit, fn)` (`@openora/core/server`).
17
+ - Audit every mutation: each state-changing action emits a domain event the `audit` add-on subscribes to, or resolves `AUDIT_WRITER` and calls `record(...)`. A mutation with no audit trail is not done.