@open-mercato/cli 0.6.7-develop.6552.1.5208a19369 → 0.6.7-develop.6565.1.1026d85fff

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 (87) hide show
  1. package/.turbo/turbo-build.log +3 -3
  2. package/AGENTS.md +1 -0
  3. package/dist/agentic/guides/module-facts.json +53 -53
  4. package/dist/agentic/guides/modules/ai_assistant.md +1 -1
  5. package/dist/agentic/guides/modules/api_docs.md +1 -1
  6. package/dist/agentic/guides/modules/api_keys.md +1 -1
  7. package/dist/agentic/guides/modules/attachments.md +1 -1
  8. package/dist/agentic/guides/modules/audit_logs.md +1 -1
  9. package/dist/agentic/guides/modules/auth.md +1 -1
  10. package/dist/agentic/guides/modules/business_rules.md +1 -1
  11. package/dist/agentic/guides/modules/catalog.md +1 -1
  12. package/dist/agentic/guides/modules/channel_gmail.md +1 -1
  13. package/dist/agentic/guides/modules/channel_imap.md +1 -1
  14. package/dist/agentic/guides/modules/checkout.md +1 -1
  15. package/dist/agentic/guides/modules/communication_channels.md +1 -1
  16. package/dist/agentic/guides/modules/configs.md +1 -1
  17. package/dist/agentic/guides/modules/content.md +1 -1
  18. package/dist/agentic/guides/modules/currencies.md +1 -1
  19. package/dist/agentic/guides/modules/customer_accounts.md +1 -1
  20. package/dist/agentic/guides/modules/customers.md +1 -1
  21. package/dist/agentic/guides/modules/dashboards.md +1 -1
  22. package/dist/agentic/guides/modules/data_sync.md +1 -1
  23. package/dist/agentic/guides/modules/dictionaries.md +1 -1
  24. package/dist/agentic/guides/modules/directory.md +1 -1
  25. package/dist/agentic/guides/modules/entities.md +1 -1
  26. package/dist/agentic/guides/modules/events.md +1 -1
  27. package/dist/agentic/guides/modules/feature_toggles.md +1 -1
  28. package/dist/agentic/guides/modules/gateway_stripe.md +1 -1
  29. package/dist/agentic/guides/modules/generators.md +1 -1
  30. package/dist/agentic/guides/modules/inbox_ops.md +1 -1
  31. package/dist/agentic/guides/modules/integrations.md +1 -1
  32. package/dist/agentic/guides/modules/messages.md +1 -1
  33. package/dist/agentic/guides/modules/notifications.md +1 -1
  34. package/dist/agentic/guides/modules/onboarding.md +1 -1
  35. package/dist/agentic/guides/modules/payment_gateways.md +1 -1
  36. package/dist/agentic/guides/modules/perspectives.md +1 -1
  37. package/dist/agentic/guides/modules/planner.md +1 -1
  38. package/dist/agentic/guides/modules/portal.md +1 -1
  39. package/dist/agentic/guides/modules/progress.md +1 -1
  40. package/dist/agentic/guides/modules/query_index.md +1 -1
  41. package/dist/agentic/guides/modules/record_locks.md +1 -1
  42. package/dist/agentic/guides/modules/resources.md +1 -1
  43. package/dist/agentic/guides/modules/sales.md +1 -1
  44. package/dist/agentic/guides/modules/scheduler.md +1 -1
  45. package/dist/agentic/guides/modules/search.md +1 -1
  46. package/dist/agentic/guides/modules/security.md +1 -1
  47. package/dist/agentic/guides/modules/shipping_carriers.md +1 -1
  48. package/dist/agentic/guides/modules/sso.md +1 -1
  49. package/dist/agentic/guides/modules/staff.md +1 -1
  50. package/dist/agentic/guides/modules/storage_s3.md +1 -1
  51. package/dist/agentic/guides/modules/sync_akeneo.md +1 -1
  52. package/dist/agentic/guides/modules/sync_excel.md +1 -1
  53. package/dist/agentic/guides/modules/system_status_overlays.md +1 -1
  54. package/dist/agentic/guides/modules/translations.md +1 -1
  55. package/dist/agentic/guides/modules/webhooks.md +1 -1
  56. package/dist/agentic/guides/modules/workflows.md +1 -1
  57. package/dist/agentic/shared/AGENTS.md.template +1 -1
  58. package/dist/agentic/shared/ai/skills/tiers.schema.json +15 -0
  59. package/dist/agentic/shared/scripts/install-skills.sh +244 -37
  60. package/dist/lib/__integration__/TC-INT-008.spec.js +9 -11
  61. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  62. package/dist/lib/agentic-setup.js +14 -2
  63. package/dist/lib/agentic-setup.js.map +2 -2
  64. package/dist/lib/generate-watch-events.js +106 -0
  65. package/dist/lib/generate-watch-events.js.map +7 -0
  66. package/dist/lib/generators/entity-ids.js +20 -13
  67. package/dist/lib/generators/entity-ids.js.map +2 -2
  68. package/dist/lib/generators/openapi.js +114 -12
  69. package/dist/lib/generators/openapi.js.map +2 -2
  70. package/dist/lib/in-process-generate-watcher.js +13 -2
  71. package/dist/lib/in-process-generate-watcher.js.map +2 -2
  72. package/dist/mercato.js +68 -23
  73. package/dist/mercato.js.map +2 -2
  74. package/package.json +5 -5
  75. package/src/__tests__/mercato.test.ts +53 -2
  76. package/src/lib/__integration__/TC-INT-008.spec.ts +15 -15
  77. package/src/lib/__tests__/generate-watch-events.test.ts +112 -0
  78. package/src/lib/__tests__/generate-watch-structure.test.ts +20 -0
  79. package/src/lib/__tests__/in-process-generate-watcher.test.ts +193 -2
  80. package/src/lib/agentic-setup.ts +23 -4
  81. package/src/lib/generate-watch-events.ts +125 -0
  82. package/src/lib/generators/__tests__/entity-ids.test.ts +98 -1
  83. package/src/lib/generators/__tests__/openapi.test.ts +174 -0
  84. package/src/lib/generators/entity-ids.ts +33 -13
  85. package/src/lib/generators/openapi.ts +167 -14
  86. package/src/lib/in-process-generate-watcher.ts +35 -6
  87. package/src/mercato.ts +75 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.7-develop.6552.1.5208a19369",
3
+ "version": "0.6.7-develop.6565.1.1026d85fff",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  "@mikro-orm/decorators": "^7.1.5",
61
61
  "@mikro-orm/migrations": "^7.1.5",
62
62
  "@mikro-orm/postgresql": "^7.1.5",
63
- "@open-mercato/queue": "0.6.7-develop.6552.1.5208a19369",
64
- "@open-mercato/shared": "0.6.7-develop.6552.1.5208a19369",
63
+ "@open-mercato/queue": "0.6.7-develop.6565.1.1026d85fff",
64
+ "@open-mercato/shared": "0.6.7-develop.6565.1.1026d85fff",
65
65
  "cross-spawn": "^7.0.6",
66
66
  "pg": "8.22.0",
67
67
  "semver": "^7.8.5",
@@ -71,10 +71,10 @@
71
71
  "typescript": "^6.0.3"
72
72
  },
73
73
  "peerDependencies": {
74
- "@open-mercato/shared": "0.6.7-develop.6552.1.5208a19369"
74
+ "@open-mercato/shared": "0.6.7-develop.6565.1.1026d85fff"
75
75
  },
76
76
  "devDependencies": {
77
- "@open-mercato/shared": "0.6.7-develop.6552.1.5208a19369",
77
+ "@open-mercato/shared": "0.6.7-develop.6565.1.1026d85fff",
78
78
  "@types/jest": "^30.0.0",
79
79
  "jest": "^30.4.2",
80
80
  "ts-jest": "^29.4.11"
@@ -547,7 +547,11 @@ describe('generate post-step structural cache purge', () => {
547
547
  it('runs structural cache purge after successful generation when configs cache CLI is available', async () => {
548
548
  const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
549
549
  const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
550
- const generateEntityIds = jest.fn().mockResolvedValue(undefined)
550
+ const generateEntityIds = jest.fn().mockResolvedValue({
551
+ filesWritten: ['/tmp/test-app/.mercato/generated/entities.ids.generated.ts'],
552
+ filesUnchanged: [],
553
+ errors: [],
554
+ })
551
555
  const generateModuleRegistry = jest.fn().mockResolvedValue(undefined)
552
556
  const generateModuleRegistryApp = jest.fn().mockResolvedValue(undefined)
553
557
  const generateModuleRegistryCli = jest.fn().mockResolvedValue(undefined)
@@ -594,10 +598,57 @@ describe('generate post-step structural cache purge', () => {
594
598
  consoleLogSpy.mockRestore()
595
599
  })
596
600
 
601
+ it('skips structural invalidation when every generated output is unchanged', async () => {
602
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
603
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
604
+ const unchangedResult = {
605
+ filesWritten: [],
606
+ filesUnchanged: ['/tmp/test-app/.mercato/generated/unchanged.generated.ts'],
607
+ errors: [],
608
+ }
609
+ const generators = {
610
+ generateEntityIds: jest.fn().mockResolvedValue(unchangedResult),
611
+ generateModuleRegistry: jest.fn().mockResolvedValue(unchangedResult),
612
+ generateModuleRegistryApp: jest.fn().mockResolvedValue(unchangedResult),
613
+ generateModuleRegistryCli: jest.fn().mockResolvedValue(unchangedResult),
614
+ generateModuleEntities: jest.fn().mockResolvedValue(unchangedResult),
615
+ generateModuleDi: jest.fn().mockResolvedValue(unchangedResult),
616
+ generateModulePackageSources: jest.fn().mockResolvedValue(unchangedResult),
617
+ generateOpenApi: jest.fn().mockResolvedValue(unchangedResult),
618
+ }
619
+ const bootstrapFromAppRoot = jest.fn()
620
+
621
+ jest.doMock('../lib/generators', () => generators)
622
+ jest.doMock('../lib/resolver', () => ({
623
+ createResolver: () => ({
624
+ getAppDir: () => '/tmp/test-app',
625
+ }),
626
+ }))
627
+ jest.doMock('@open-mercato/shared/lib/bootstrap/dynamicLoader', () => ({
628
+ bootstrapFromAppRoot,
629
+ }))
630
+
631
+ const mercato = await import('../mercato')
632
+ const exitCode = await mercato.run(['node', 'mercato', 'generate'])
633
+
634
+ expect(exitCode).toBe(0)
635
+ expect(bootstrapFromAppRoot).not.toHaveBeenCalled()
636
+ expect(consoleLogSpy).toHaveBeenCalledWith(
637
+ '[generate] Generated outputs unchanged; skipping structural cache purge.',
638
+ )
639
+
640
+ consoleErrorSpy.mockRestore()
641
+ consoleLogSpy.mockRestore()
642
+ })
643
+
597
644
  it('keeps generation successful when the post-generate cache purge bootstrap fails', async () => {
598
645
  const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
599
646
  const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
600
- const generateEntityIds = jest.fn().mockResolvedValue(undefined)
647
+ const generateEntityIds = jest.fn().mockResolvedValue({
648
+ filesWritten: ['/tmp/test-app/.mercato/generated/entities.ids.generated.ts'],
649
+ filesUnchanged: [],
650
+ errors: [],
651
+ })
601
652
  const generateModuleRegistry = jest.fn().mockResolvedValue(undefined)
602
653
  const generateModuleRegistryApp = jest.fn().mockResolvedValue(undefined)
603
654
  const generateModuleRegistryCli = jest.fn().mockResolvedValue(undefined)
@@ -282,28 +282,28 @@ test.describe('TC-INT-008: CLI agentic init mirrors standalone scaffolding asset
282
282
  expect(fs.existsSync(path.join(appDir, '.ai', 'skills', 'om-spec-writing'))).toBe(false)
283
283
  expect(fs.existsSync(path.join(appDir, 'scripts', 'install-skills.sh'))).toBe(true)
284
284
 
285
- // install-skills.sh (run by agentic:init) replaces the legacy directory-level
286
- // .claude/skills and .codex/skills symlinks with real directories holding one
287
- // symlink per default-tier local skill; .cursor/skills keeps the directory link.
288
- const cursorSkillsLinkPath = path.join(appDir, '.cursor', 'skills')
289
- expect(fs.lstatSync(cursorSkillsLinkPath).isSymbolicLink()).toBe(true)
290
- expect(normalizePath(fs.readlinkSync(cursorSkillsLinkPath))).toBe('../.ai/skills')
291
-
285
+ // install-skills.sh (run by agentic:init) installs every local tier skill once,
286
+ // into the canonical .agents/skills/. Codex and Cursor read that directory
287
+ // natively, so they get no skills directory of their own; Claude Code cannot,
288
+ // so it keeps a link layer pointing back at the canonical copy.
292
289
  const tiersManifest = JSON.parse(
293
290
  fs.readFileSync(path.join(agenticRoot, 'shared', 'ai', 'skills', 'tiers.json'), 'utf8'),
294
291
  ) as { default: string[]; tiers: Record<string, { skills: string[] }> }
295
292
  const defaultTierSkills = tiersManifest.default.flatMap((tierName) => tiersManifest.tiers[tierName].skills)
296
293
  expect(defaultTierSkills.length).toBeGreaterThan(0)
297
294
 
298
- for (const toolDir of ['.claude', '.codex']) {
299
- const harnessSkillsDir = path.join(appDir, toolDir, 'skills')
300
- expect(fs.lstatSync(harnessSkillsDir).isDirectory()).toBe(true)
301
- for (const skillName of defaultTierSkills) {
302
- const skillLinkPath = path.join(harnessSkillsDir, skillName)
303
- expect(fs.lstatSync(skillLinkPath).isSymbolicLink()).toBe(true)
304
- expect(normalizePath(fs.readlinkSync(skillLinkPath))).toBe(`../../.ai/skills/${skillName}`)
305
- }
295
+ for (const skillName of defaultTierSkills) {
296
+ const canonicalLinkPath = path.join(appDir, '.agents', 'skills', skillName)
297
+ expect(fs.lstatSync(canonicalLinkPath).isSymbolicLink()).toBe(true)
298
+ expect(normalizePath(fs.readlinkSync(canonicalLinkPath))).toBe(`../../.ai/skills/${skillName}`)
299
+
300
+ const claudeLinkPath = path.join(appDir, '.claude', 'skills', skillName)
301
+ expect(fs.lstatSync(claudeLinkPath).isSymbolicLink()).toBe(true)
302
+ expect(normalizePath(fs.readlinkSync(claudeLinkPath))).toBe(`../../.agents/skills/${skillName}`)
306
303
  }
304
+
305
+ expect(fs.existsSync(path.join(appDir, '.codex', 'skills'))).toBe(false)
306
+ expect(fs.existsSync(path.join(appDir, '.cursor', 'skills'))).toBe(false)
307
307
  } finally {
308
308
  fs.rmSync(tempRoot, { recursive: true, force: true })
309
309
  }
@@ -0,0 +1,112 @@
1
+ import path from 'node:path'
2
+ import {
3
+ createGenerateWatchChangeSignal,
4
+ type GenerateWatchTarget,
5
+ } from '../generate-watch-events'
6
+
7
+ type RegisteredWatcher = {
8
+ target: GenerateWatchTarget
9
+ onChange: () => void
10
+ onError: () => void
11
+ close: jest.Mock
12
+ }
13
+
14
+ function createWatchHarness(initialTargets: GenerateWatchTarget[]) {
15
+ let targets = initialTargets
16
+ const registered: RegisteredWatcher[] = []
17
+ const watchDirectory = jest.fn((
18
+ target: GenerateWatchTarget,
19
+ onChange: () => void,
20
+ onError: () => void,
21
+ ) => {
22
+ const watcher = { target, onChange, onError, close: jest.fn() }
23
+ registered.push(watcher)
24
+ return watcher
25
+ })
26
+ const signal = createGenerateWatchChangeSignal({
27
+ getWatchTargets: () => targets,
28
+ watchDirectory,
29
+ })
30
+
31
+ return {
32
+ signal,
33
+ registered,
34
+ watchDirectory,
35
+ setTargets: (nextTargets: GenerateWatchTarget[]) => { targets = nextTargets },
36
+ }
37
+ }
38
+
39
+ describe('createGenerateWatchChangeSignal', () => {
40
+ it('deduplicates targets and increments its version on filesystem events', async () => {
41
+ const target = { directory: './modules', recursive: true }
42
+ const { signal, registered, watchDirectory } = createWatchHarness([target, target])
43
+
44
+ await signal.refresh()
45
+ expect(watchDirectory).toHaveBeenCalledTimes(1)
46
+ expect(registered[0].target.directory).toBe(path.resolve('./modules'))
47
+ expect(signal.currentVersion()).toBe(0)
48
+
49
+ registered[0].onChange()
50
+ registered[0].onChange()
51
+ expect(signal.currentVersion()).toBe(2)
52
+ expect(signal.usesPollingFallback()).toBe(false)
53
+ })
54
+
55
+ it('refreshes changed watch roots without recreating stable watchers', async () => {
56
+ const first = { directory: './modules-a', recursive: true }
57
+ const second = { directory: './modules-b', recursive: true }
58
+ const harness = createWatchHarness([first])
59
+
60
+ await harness.signal.refresh()
61
+ harness.setTargets([first, second])
62
+ await harness.signal.refresh()
63
+ expect(harness.watchDirectory).toHaveBeenCalledTimes(2)
64
+ expect(harness.registered[0].close).not.toHaveBeenCalled()
65
+
66
+ harness.setTargets([second])
67
+ await harness.signal.refresh()
68
+ expect(harness.registered[0].close).toHaveBeenCalledTimes(1)
69
+ expect(harness.registered[1].close).not.toHaveBeenCalled()
70
+ })
71
+
72
+ it('falls back to checksum polling when watcher setup fails', async () => {
73
+ const firstClose = jest.fn()
74
+ const watchDirectory = jest.fn()
75
+ .mockReturnValueOnce({ close: firstClose })
76
+ .mockImplementationOnce(() => { throw new Error('watch unavailable') })
77
+ const signal = createGenerateWatchChangeSignal({
78
+ getWatchTargets: () => [
79
+ { directory: './modules-a', recursive: true },
80
+ { directory: './modules-b', recursive: true },
81
+ ],
82
+ watchDirectory,
83
+ })
84
+
85
+ await signal.refresh()
86
+ expect(signal.usesPollingFallback()).toBe(true)
87
+ expect(signal.currentVersion()).toBe(1)
88
+ expect(firstClose).toHaveBeenCalledTimes(1)
89
+ })
90
+
91
+ it('falls back to checksum polling when an active watcher errors', async () => {
92
+ const { signal, registered } = createWatchHarness([
93
+ { directory: './modules', recursive: true },
94
+ ])
95
+
96
+ await signal.refresh()
97
+ registered[0].onError()
98
+ expect(signal.usesPollingFallback()).toBe(true)
99
+ expect(registered[0].close).toHaveBeenCalledTimes(1)
100
+ })
101
+
102
+ it('closes filesystem watchers exactly once', async () => {
103
+ const { signal, registered } = createWatchHarness([
104
+ { directory: './modules', recursive: true },
105
+ ])
106
+
107
+ await signal.refresh()
108
+ await signal.close()
109
+ await signal.close()
110
+ expect(registered[0].close).toHaveBeenCalledTimes(1)
111
+ })
112
+ })
@@ -77,4 +77,24 @@ describe('calculateGenerateWatchStructureChecksum', () => {
77
77
 
78
78
  expect(currentChecksum()).not.toBe(before)
79
79
  })
80
+
81
+ it('changes when a discovered worker is added and removed', () => {
82
+ const before = currentChecksum()
83
+ const workerPath = path.join(pkgModule, 'workers', 'sync-customers.ts')
84
+
85
+ write(workerPath, 'export default async function syncCustomers() {}\n')
86
+ const afterAdd = currentChecksum()
87
+ expect(afterAdd).not.toBe(before)
88
+
89
+ fs.rmSync(workerPath)
90
+ expect(currentChecksum()).toBe(before)
91
+ })
92
+
93
+ it('changes when the module registry configuration changes', () => {
94
+ const before = currentChecksum()
95
+
96
+ write(path.join(appDir, 'src', 'modules.ts'), 'export const enabledModules = ["customers"]\n')
97
+
98
+ expect(currentChecksum()).not.toBe(before)
99
+ })
80
100
  })
@@ -1,14 +1,32 @@
1
- import { startInProcessGenerateWatcher } from '../in-process-generate-watcher'
1
+ import {
2
+ startInProcessGenerateWatcher,
3
+ type GenerateWatcherChangeSignal,
4
+ } from '../in-process-generate-watcher'
2
5
 
3
6
  const silentLogger = { log: jest.fn(), error: jest.fn() }
4
7
 
5
8
  async function flushAsync(times = 5): Promise<void> {
6
9
  for (let i = 0; i < times; i++) {
7
- // eslint-disable-next-line no-await-in-loop
8
10
  await Promise.resolve()
9
11
  }
10
12
  }
11
13
 
14
+ function createFakeChangeSignal() {
15
+ let version = 0
16
+ let fallback = false
17
+ const signal: GenerateWatcherChangeSignal = {
18
+ currentVersion: () => version,
19
+ refresh: jest.fn(async () => undefined),
20
+ usesPollingFallback: () => fallback,
21
+ close: jest.fn(async () => undefined),
22
+ }
23
+ return {
24
+ signal,
25
+ markChanged: () => { version += 1 },
26
+ usePollingFallback: () => { fallback = true },
27
+ }
28
+ }
29
+
12
30
  describe('startInProcessGenerateWatcher', () => {
13
31
  beforeEach(() => {
14
32
  jest.useFakeTimers()
@@ -97,6 +115,179 @@ describe('startInProcessGenerateWatcher', () => {
97
115
  await handle.done
98
116
  })
99
117
 
118
+ it('does not recompute the full checksum during event-gated idle polls', async () => {
119
+ const runGenerators = jest.fn(async () => undefined)
120
+ const computeStructureChecksum = jest.fn(async () => 'stable')
121
+ const { signal } = createFakeChangeSignal()
122
+ const handle = startInProcessGenerateWatcher({
123
+ pollMs: 1000,
124
+ skipInitial: true,
125
+ quiet: true,
126
+ logger: silentLogger,
127
+ changeSignal: signal,
128
+ computeStructureChecksum,
129
+ runGenerators,
130
+ })
131
+
132
+ await flushAsync(8)
133
+ expect(computeStructureChecksum).toHaveBeenCalledTimes(1)
134
+
135
+ for (let poll = 0; poll < 20; poll += 1) {
136
+ jest.advanceTimersByTime(1000)
137
+ await flushAsync(8)
138
+ }
139
+
140
+ expect(computeStructureChecksum).toHaveBeenCalledTimes(1)
141
+ expect(runGenerators).not.toHaveBeenCalled()
142
+ await handle.close()
143
+ })
144
+
145
+ it('coalesces filesystem event bursts into one checksum and regeneration', async () => {
146
+ let currentChecksum = 'before'
147
+ const runGenerators = jest.fn(async () => undefined)
148
+ const computeStructureChecksum = jest.fn(async () => currentChecksum)
149
+ const { signal, markChanged } = createFakeChangeSignal()
150
+ const handle = startInProcessGenerateWatcher({
151
+ pollMs: 1000,
152
+ skipInitial: true,
153
+ quiet: true,
154
+ logger: silentLogger,
155
+ changeSignal: signal,
156
+ computeStructureChecksum,
157
+ runGenerators,
158
+ })
159
+
160
+ await flushAsync(8)
161
+ currentChecksum = 'after'
162
+ markChanged()
163
+ markChanged()
164
+ markChanged()
165
+ jest.advanceTimersByTime(1000)
166
+ await flushAsync(12)
167
+
168
+ expect(computeStructureChecksum).toHaveBeenCalledTimes(2)
169
+ expect(runGenerators).toHaveBeenCalledTimes(1)
170
+ expect(runGenerators).toHaveBeenCalledWith('structure change')
171
+ await handle.close()
172
+ })
173
+
174
+ it('validates an unrelated filesystem event without regenerating', async () => {
175
+ const runGenerators = jest.fn(async () => undefined)
176
+ const computeStructureChecksum = jest.fn(async () => 'stable')
177
+ const { signal, markChanged } = createFakeChangeSignal()
178
+ const handle = startInProcessGenerateWatcher({
179
+ pollMs: 1000,
180
+ skipInitial: true,
181
+ quiet: true,
182
+ logger: silentLogger,
183
+ changeSignal: signal,
184
+ computeStructureChecksum,
185
+ runGenerators,
186
+ })
187
+
188
+ await flushAsync(8)
189
+ markChanged()
190
+ jest.advanceTimersByTime(1000)
191
+ await flushAsync(12)
192
+
193
+ expect(computeStructureChecksum).toHaveBeenCalledTimes(2)
194
+ expect(runGenerators).not.toHaveBeenCalled()
195
+ await handle.close()
196
+ })
197
+
198
+ it('retries a dirty checksum after a transient checksum failure', async () => {
199
+ const runGenerators = jest.fn(async () => undefined)
200
+ const computeStructureChecksum = jest.fn()
201
+ .mockResolvedValueOnce('before')
202
+ .mockRejectedValueOnce(new Error('temporary read failure'))
203
+ .mockResolvedValue('after')
204
+ const { signal, markChanged } = createFakeChangeSignal()
205
+ const handle = startInProcessGenerateWatcher({
206
+ pollMs: 1000,
207
+ skipInitial: true,
208
+ quiet: true,
209
+ logger: silentLogger,
210
+ changeSignal: signal,
211
+ computeStructureChecksum,
212
+ runGenerators,
213
+ })
214
+
215
+ await flushAsync(8)
216
+ markChanged()
217
+ jest.advanceTimersByTime(1000)
218
+ await flushAsync(8)
219
+ expect(runGenerators).not.toHaveBeenCalled()
220
+
221
+ jest.advanceTimersByTime(1000)
222
+ await flushAsync(12)
223
+ expect(computeStructureChecksum).toHaveBeenCalledTimes(3)
224
+ expect(runGenerators).toHaveBeenCalledTimes(1)
225
+ await handle.close()
226
+ })
227
+
228
+ it('keeps an event that arrives while a checksum is in flight dirty for the next poll', async () => {
229
+ let releaseChecksum: ((value: string) => void) | null = null
230
+ const runGenerators = jest.fn(async () => undefined)
231
+ const computeStructureChecksum = jest.fn()
232
+ .mockResolvedValueOnce('before')
233
+ .mockImplementationOnce(() => new Promise<string>((resolve) => {
234
+ releaseChecksum = resolve
235
+ }))
236
+ .mockResolvedValue('after')
237
+ const { signal, markChanged } = createFakeChangeSignal()
238
+ const handle = startInProcessGenerateWatcher({
239
+ pollMs: 1000,
240
+ skipInitial: true,
241
+ quiet: true,
242
+ logger: silentLogger,
243
+ changeSignal: signal,
244
+ computeStructureChecksum,
245
+ runGenerators,
246
+ })
247
+
248
+ await flushAsync(8)
249
+ markChanged()
250
+ jest.advanceTimersByTime(1000)
251
+ await flushAsync(8)
252
+ expect(computeStructureChecksum).toHaveBeenCalledTimes(2)
253
+
254
+ markChanged()
255
+ releaseChecksum?.('after')
256
+ await flushAsync(12)
257
+ expect(runGenerators).toHaveBeenCalledTimes(1)
258
+
259
+ jest.advanceTimersByTime(1000)
260
+ await flushAsync(12)
261
+ expect(computeStructureChecksum).toHaveBeenCalledTimes(3)
262
+ await handle.close()
263
+ })
264
+
265
+ it('falls back to full checksum polling when filesystem watching is unavailable', async () => {
266
+ const runGenerators = jest.fn(async () => undefined)
267
+ const computeStructureChecksum = jest.fn(async () => 'stable')
268
+ const { signal, usePollingFallback } = createFakeChangeSignal()
269
+ usePollingFallback()
270
+ const handle = startInProcessGenerateWatcher({
271
+ pollMs: 1000,
272
+ skipInitial: true,
273
+ quiet: true,
274
+ logger: silentLogger,
275
+ changeSignal: signal,
276
+ computeStructureChecksum,
277
+ runGenerators,
278
+ })
279
+
280
+ await flushAsync(8)
281
+ jest.advanceTimersByTime(1000)
282
+ await flushAsync(8)
283
+ jest.advanceTimersByTime(1000)
284
+ await flushAsync(8)
285
+
286
+ expect(computeStructureChecksum).toHaveBeenCalledTimes(3)
287
+ await handle.close()
288
+ expect(signal.close).toHaveBeenCalledTimes(1)
289
+ })
290
+
100
291
  it('does not start a new poll while a regeneration is in flight', async () => {
101
292
  let release: (() => void) | null = null
102
293
  const runGenerators = jest.fn(async (_reason: string) => {
@@ -409,8 +409,8 @@ function generateCodex(config: AgenticConfig): void {
409
409
 
410
410
  copyFile(srcDir, 'mcp.json.example', join(targetDir, '.codex', 'mcp.json.example'))
411
411
 
412
- // Symlink .codex/skills → ../.ai/skills
413
- ensureSkillsLink(join(targetDir, '.codex', 'skills'), join('..', '.ai', 'skills'))
412
+ // No .codex/skills directory: Codex reads the canonical .agents/skills/,
413
+ // which scripts/install-skills.sh populates.
414
414
  }
415
415
 
416
416
  function generateCursor(config: AgenticConfig): void {
@@ -424,8 +424,8 @@ function generateCursor(config: AgenticConfig): void {
424
424
  copyFile(srcDir, 'hooks/entity-migration-check.mjs', join(targetDir, '.cursor', 'hooks', 'entity-migration-check.mjs'))
425
425
  copyFile(srcDir, 'mcp.json.example', join(targetDir, '.cursor', 'mcp.json.example'))
426
426
 
427
- // Symlink .cursor/skills → ../.ai/skills
428
- ensureSkillsLink(join(targetDir, '.cursor', 'skills'), join('..', '.ai', 'skills'))
427
+ // No .cursor/skills directory: Cursor reads the canonical .agents/skills/,
428
+ // which scripts/install-skills.sh populates.
429
429
  }
430
430
 
431
431
  function ensureSkillsLink(linkPath: string, target: string): void {
@@ -518,6 +518,7 @@ export async function runAgenticSetup(
518
518
  if (selectedIds.includes('codex')) generateCodex(config)
519
519
  if (selectedIds.includes('cursor')) generateCursor(config)
520
520
 
521
+ persistAgentSelection(targetDir, selectedIds)
521
522
  installSkills(targetDir)
522
523
 
523
524
  console.log('')
@@ -539,6 +540,24 @@ export async function runAgenticSetup(
539
540
  console.log('')
540
541
  }
541
542
 
543
+ /**
544
+ * Persist the agent selection so later `yarn install-skills` runs keep honoring
545
+ * it: agents the user did not pick go into `agents.ignore` in tiers.json and
546
+ * never get a skills directory of their own.
547
+ */
548
+ function persistAgentSelection(targetDir: string, selectedIds: string[]): void {
549
+ const manifestPath = join(targetDir, '.ai', 'skills', 'tiers.json')
550
+ if (!existsSync(manifestPath)) return
551
+ const ignore = SELECTABLE.map((tool) => tool.id).filter((id) => !selectedIds.includes(id))
552
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as Record<string, unknown>
553
+ if (ignore.length > 0) {
554
+ manifest.agents = { ignore }
555
+ } else {
556
+ delete manifest.agents
557
+ }
558
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
559
+ }
560
+
542
561
  function installSkills(targetDir: string): void {
543
562
  const installScript = join(targetDir, 'scripts', 'install-skills.sh')
544
563
  if (!existsSync(installScript)) return
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { GenerateWatcherChangeSignal } from './in-process-generate-watcher'
4
+
5
+ export type GenerateWatchTarget = {
6
+ directory: string
7
+ recursive: boolean
8
+ fileName?: string
9
+ }
10
+
11
+ type WatchHandle = {
12
+ close(): void
13
+ }
14
+
15
+ type WatchDirectory = (
16
+ target: GenerateWatchTarget,
17
+ onChange: (fileName?: string) => void,
18
+ onError: () => void,
19
+ ) => WatchHandle
20
+
21
+ export type GenerateWatchChangeSignalOptions = {
22
+ getWatchTargets: () => Promise<GenerateWatchTarget[]> | GenerateWatchTarget[]
23
+ watchDirectory?: WatchDirectory
24
+ }
25
+
26
+ function targetKey(target: GenerateWatchTarget): string {
27
+ return JSON.stringify([
28
+ path.resolve(target.directory),
29
+ target.recursive,
30
+ target.fileName ?? '',
31
+ ])
32
+ }
33
+
34
+ const defaultWatchDirectory: WatchDirectory = (target, onChange, onError) => {
35
+ const watcher = fs.watch(
36
+ target.directory,
37
+ { recursive: target.recursive },
38
+ (_eventType, fileName) => {
39
+ const normalizedName = String(fileName ?? '')
40
+ if (target.fileName && normalizedName && normalizedName !== target.fileName) return
41
+ onChange(normalizedName || undefined)
42
+ },
43
+ )
44
+ watcher.on('error', onError)
45
+ return watcher
46
+ }
47
+
48
+ export function createGenerateWatchChangeSignal(
49
+ options: GenerateWatchChangeSignalOptions,
50
+ ): GenerateWatcherChangeSignal {
51
+ const watchDirectory = options.watchDirectory ?? defaultWatchDirectory
52
+ const watchers = new Map<string, WatchHandle>()
53
+ let version = 0
54
+ let pollingFallback = false
55
+ let closed = false
56
+
57
+ const closeWatchers = () => {
58
+ for (const watcher of watchers.values()) {
59
+ try { watcher.close() } catch {}
60
+ }
61
+ watchers.clear()
62
+ }
63
+
64
+ const enterPollingFallback = () => {
65
+ if (pollingFallback) return
66
+ pollingFallback = true
67
+ version += 1
68
+ closeWatchers()
69
+ }
70
+
71
+ return {
72
+ currentVersion: () => version,
73
+ usesPollingFallback: () => pollingFallback,
74
+ refresh: async () => {
75
+ if (closed || pollingFallback) return
76
+
77
+ let targets: GenerateWatchTarget[]
78
+ try {
79
+ targets = await options.getWatchTargets()
80
+ } catch {
81
+ enterPollingFallback()
82
+ return
83
+ }
84
+
85
+ const normalizedTargets = new Map<string, GenerateWatchTarget>()
86
+ for (const target of targets) {
87
+ const normalized: GenerateWatchTarget = {
88
+ ...target,
89
+ directory: path.resolve(target.directory),
90
+ }
91
+ normalizedTargets.set(targetKey(normalized), normalized)
92
+ }
93
+
94
+ for (const [key, watcher] of watchers) {
95
+ if (normalizedTargets.has(key)) continue
96
+ try { watcher.close() } catch {}
97
+ watchers.delete(key)
98
+ }
99
+
100
+ for (const [key, target] of normalizedTargets) {
101
+ if (watchers.has(key)) continue
102
+ try {
103
+ const watcher = watchDirectory(
104
+ target,
105
+ () => { version += 1 },
106
+ enterPollingFallback,
107
+ )
108
+ if (closed || pollingFallback) {
109
+ try { watcher.close() } catch {}
110
+ continue
111
+ }
112
+ watchers.set(key, watcher)
113
+ } catch {
114
+ enterPollingFallback()
115
+ return
116
+ }
117
+ }
118
+ },
119
+ close: () => {
120
+ if (closed) return
121
+ closed = true
122
+ closeWatchers()
123
+ },
124
+ }
125
+ }